refactor page loader

This commit is contained in:
evilchili 2025-10-22 19:20:47 -07:00
parent 8fd28cf8b1
commit 6afab2a15c
5 changed files with 239 additions and 101 deletions

View File

@ -11,7 +11,12 @@ from tinydb import where
from tinydb.storages import MemoryStorage from tinydb.storages import MemoryStorage
from ttfrog import schema from ttfrog import schema
from ttfrog.exceptions import ApplicationNotInitializedError from ttfrog.exceptions import (
ApplicationNotInitializedError,
MalformedRequestError,
RecordNotFoundError,
UnauthorizedError,
)
class ApplicationContext: class ApplicationContext:
@ -46,6 +51,7 @@ ADMIN_EMAIL=admin@telisar
THEME=default THEME=default
VIEW_URI=/ VIEW_URI=/
API_URI=/_/v1/
""" """
@ -108,6 +114,8 @@ VIEW_URI=/
Session(self.web) Session(self.web)
self.log = self.web.logger
self._initialized = True self._initialized = True
def check_state(self) -> None: def check_state(self) -> None:
@ -119,16 +127,16 @@ VIEW_URI=/
Returns the User record matching the given username and password Returns the User record matching the given username and password
""" """
if not (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 return None
user = self.db.User.get(where("name") == username) user = self.db.User.get(where("name") == username)
if not user: if not user:
self.web.logger.debug(f"No user matching {username}") self.log.debug(f"No user matching {username}")
return None return None
if not user.check_credentials(username, password): 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 None
return user return user
@ -136,5 +144,97 @@ VIEW_URI=/
def authorize(self, user, record, requested): def authorize(self, user, record, requested):
return user.has_permission(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() sys.modules[__name__] = ApplicationContext()

View File

@ -3,3 +3,21 @@ class ApplicationNotInitializedError(Exception):
Thrown when attempting to access methods on the Thrown when attempting to access methods on the
ApplicationContext before it has been initialized. 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.
"""

View File

@ -2,11 +2,11 @@ from dataclasses import dataclass, field
from functools import cached_property from functools import cached_property
from flask import g 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 from ttfrog import schema
READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference] READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference, Timestamp]
@dataclass @dataclass
@ -22,7 +22,7 @@ class Form:
def read_only(self) -> set: def read_only(self) -> set:
return [ return [
name for (name, attr) in self.record._metadata.fields.items() if type(attr) in READ_ONLY_FIELD_TYPES name for (name, attr) in self.record._metadata.fields.items() if type(attr) in READ_ONLY_FIELD_TYPES
] + ["uid"] ] + ["uid", "acl"]
def prepare(self): def prepare(self):
for key, value in self.data.items(): for key, value in self.data.items():
@ -43,9 +43,14 @@ class Page(Form):
record: schema.Page record: schema.Page
@cached_property
def read_only(self) -> set: @dataclass
return set(list(super().read_only) + ["stub"]) class Wiki(Form):
"""
A form for creating and updating Wiki records.
"""
record: schema.Page
@dataclass @dataclass

View File

@ -6,6 +6,27 @@ var pageContent = null;
var saveButton = null; var saveButton = null;
var editorUI = 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() { isReadOnly = function() {
if (editor) { if (editor) {
@ -53,7 +74,17 @@ makeSaveButton = function() {
button.className = 'actions'; button.className = 'actions';
button.innerHTML = 'save'; button.innerHTML = 'save';
button.id = 'saveButton'; button.id = 'saveButton';
button.style.border = "1px solid black";
button.addEventListener('click', () => { 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; saveButton = button;
return button; return button;

View File

@ -1,67 +1,49 @@
from flask import Response, g, redirect, render_template, request, session, url_for import json
from tinydb import where
from flask import Response, g, jsonify, redirect, render_template, request, session, url_for
from ttfrog import app, forms, schema from ttfrog import app, forms, schema
from ttfrog.exceptions import MalformedRequestError, RecordNotFoundError, UnauthorizedError
def relative_uri(path: str = ""): def get_page(
""" path: str, table: str = "Page", doc_id: int = None, create_okay: bool = False
The request's URI relative to the VIEW_URI without the leading '/'. ) -> (schema.Record | None, Exception | None):
"""
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):
""" """
Get one page, including its members, but not recursively. Get one page, including its members, but not recursively.
""" """
uri = relative_uri(path) try:
page = app.get_page(g.user, table, doc_id=doc_id, uri=path)
if table not in app.db.tables(): except (UnauthorizedError, MalformedRequestError) as e:
app.web.logger.debug(f"Table {table} does not exist in {app.db.tables()}.") return None, e
return None except RecordNotFoundError as e:
page = app.db.table(table).get(where("uri") == uri, recurse=False)
if not page:
app.web.logger.debug("Page does not exist.")
if not create_okay: if not create_okay:
app.web.logger.debug("Page does not exist and creating is not okay.") return None, e
return None return page, 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)
if not app.authorize(g.user, page, schema.Permissions.READ):
return None
if hasattr(page, "members"): def api_response(response={}, messages=[], error=None):
subpages = [] response_code = 200
for pointer in page.members:
table, pkey, pval = pointer.split("::")
subpages += app.db.table(table).search(where(pkey) == pval, recurse=False)
page.members = subpages
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"): def rendered(page: schema.Record, template: str = "page.html"):
if not page: if not page:
return Response("Page not found", status=404) 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(): def breadcrumbs():
@ -69,14 +51,36 @@ def breadcrumbs():
Return (uri, name) pairs for the parents leading from the VIEW_URI to the current request. Return (uri, name) pairs for the parents leading from the VIEW_URI to the current request.
""" """
uri = "" 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]) uri = "/".join([uri, name])
yield (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) @app.web.route(app.config.VIEW_URI)
def index(): 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"]) @app.web.route("/login", methods=["GET", "POST"])
@ -92,7 +96,7 @@ def login():
session["user"] = dict(g.user.serialize()) session["user"] = dict(g.user.serialize())
return redirect(url_for("index")) return redirect(url_for("index"))
g.messages.append(f"Invalid login for {username}") 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") @app.web.route("/logout")
@ -106,51 +110,31 @@ def logout():
@app.web.route(f"{app.config.VIEW_URI}/<path:table>/<path:path>", methods=["GET"]) @app.web.route(f"{app.config.VIEW_URI}/<path:table>/<path:path>", methods=["GET"])
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["GET"], defaults={"table": "Page"}) @app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["GET"], defaults={"table": "Page"})
def view(table, path): def view(table, path):
parent = get_parent(table, relative_uri()) page, error = get_page(request.path, table=table, create_okay=True)
if table not in app.db.tables(): if error:
table = parent.__class__.__name__ if parent else "Page" g.messages.append(str(error))
page = get_page(request.path, table=table, create_okay=(parent and parent.doc_id is not None))
return rendered(page) return rendered(page)
@app.web.route(f"{app.config.VIEW_URI}/<path:table>/<path:path>", methods=["POST"]) @app.web.route(f"{app.config.API_URI}/put/<path:table>/<path:path>", methods=["POST"])
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", methods=["POST"], defaults={"table": "Page"}) @app.web.route(f"{app.config.API_URI}/put/<path:path>", methods=["POST"], defaults={"table": "Page"})
def edit(table, path): def put(table, path):
uri = relative_uri() app.log.debug(f"Checking for page at {table}/{path} in {table} space")
parent = get_parent(table, uri) page, error = get_page("/".join([table, path]), table=table, create_okay=True)
if not parent: app.log.debug(f"Found {page.doc_id}")
return Response("You cannot create a page at this location.", status=403) if error:
return api_response(error=error)
# get or create the docoument at this uri params = json.loads(request.data.decode())["body"]
page = get_page(uri, table=table, create_okay=True) save_data = getattr(forms, table)(page, params).prepare()
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()
# editing existing document doc = app.db.save(save_data) if page.doc_id else page.parent.add_member(save_data)
if page.doc_id: app.log.debug(f"Saved: {dict(doc)=}")
if page.uid != request.form["uid"]: return api_response(response=dict(doc))
return Response("Invalid UID.", status=403)
return rendered(app.db.save(save_data))
# saving a new document
return rendered(parent.add_member(save_data))
@app.web.before_request @app.web.route(f"{app.config.API_URI}/get/<path:table>/<int:doc_id>", methods=["GET"])
def before_request(): def get(table, doc_id):
g.messages = [] app.log.debug(f"API: getting {table}({doc_id})")
if not request.path.startswith("/static"): page, error = get_page(g.user, table=table, doc_id=doc_id)
user_id = session.get("user_id", 1) return api_response(response=dict(page), error=error)
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