refactor page loader
This commit is contained in:
parent
8fd28cf8b1
commit
6afab2a15c
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}/<path:table>/<path:path>", methods=["GET"])
|
||||
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", 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}/<path:table>/<path:path>", methods=["POST"])
|
||||
@app.web.route(f"{app.config.VIEW_URI}/<path:path>", 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/<path:table>/<path:path>", methods=["POST"])
|
||||
@app.web.route(f"{app.config.API_URI}/put/<path:path>", 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/<path:table>/<int:doc_id>", 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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user