vtt/src/ttfrog/app.py

235 lines
8.3 KiB
Python
Raw Normal View History

import io
2025-09-24 01:28:23 -07:00
import sys
from pathlib import Path
from types import SimpleNamespace
2025-09-21 22:11:56 -07:00
from dotenv import dotenv_values
2025-10-05 00:15:37 -07:00
from flask import Flask
2025-10-04 10:48:18 -07:00
from flask_session import Session
2025-09-24 22:03:30 -07:00
from grung.db import GrungDB
2025-10-07 01:18:36 -07:00
from tinydb import where
2025-09-24 01:28:23 -07:00
from tinydb.storages import MemoryStorage
2025-09-21 22:11:56 -07:00
2025-09-24 22:03:30 -07:00
from ttfrog import schema
2025-10-22 19:20:47 -07:00
from ttfrog.exceptions import (
ApplicationNotInitializedError,
MalformedRequestError,
RecordNotFoundError,
UnauthorizedError,
)
2025-09-21 22:11:56 -07:00
class ApplicationContext:
"""
The global context for the application, this class provides access to the Flask app instance, the GrungDB instance,
and the loaded configuration.
To prevent multiple contexts from being created, the class is instantiated at import time and replaces the module in
the symbol table. The first time it is imported, callers should call both .load_config() and .initialize(); this is
typically done at program start.
After being intialized, callers can import ttfrog.app and interact with the ApplicationContext instance directly:
>>> from ttfrog import app
>>> print(app.config.NAME)
ttfrog
"""
CONFIG_DEFAULTS = """
# ttfrog Defaults
NAME=ttfrog
LOG_LEVEL=INFO
SECRET_KEY=fnord
IN_MEMORY_DB=
DATA_ROOT=~/.dnd/ttfrog/
ADMIN_USERNAME=admin
2025-10-04 01:26:09 -07:00
ADMIN_EMAIL=admin@telisar
2025-09-27 16:20:08 -07:00
THEME=default
VIEW_URI=/
2025-10-22 19:20:47 -07:00
API_URI=/_/v1/
"""
2025-09-24 01:28:23 -07:00
def __init__(self):
self.config: SimpleNamespace = None
self.web: Flask = None
2025-09-24 22:03:30 -07:00
self.db: GrungDB = None
2025-09-21 22:11:56 -07:00
self._initialized = False
def load_config(self, defaults: Path | None = Path("~/.dnd/ttfrog/defaults"), **overrides) -> None:
"""
Load the user configuration from the following in sources, in order:
1. ApplicationContext.CONFIG_DEFAULTS
2. The user's configuration defaults file, if any
3. Overrides specified by the caller, if any
Once the configuration is loaded, the path attribute is also configured.
"""
config_file = defaults.expanduser() if defaults else None
self.config = SimpleNamespace(
**{
**dotenv_values(stream=io.StringIO(ApplicationContext.CONFIG_DEFAULTS)),
**(dotenv_values(config_file) if config_file else {}),
**overrides,
}
)
data_root = Path(self.config.DATA_ROOT).expanduser()
self.path = SimpleNamespace(
config=config_file,
data_root=data_root,
database=data_root / f"{self.config.NAME}.json",
2025-10-05 00:15:37 -07:00
sessions=data_root / "session_cache",
)
def initialize(self, db: GrungDB = None, force: bool = False) -> None:
"""
Instantiate both the database and the flask application.
"""
if force or not self._initialized:
2025-10-05 00:15:37 -07:00
if db:
self.db = db
elif self.config.IN_MEMORY_DB:
2025-10-18 17:26:21 -07:00
self.db = GrungDB.with_schema(schema, path=None, storage=MemoryStorage)
2025-09-24 22:03:30 -07:00
else:
2025-09-28 14:14:16 -07:00
self.db = GrungDB.with_schema(
2025-10-18 17:26:21 -07:00
schema, path=self.path.database, sort_keys=True, indent=4, separators=(",", ": ")
2025-09-28 14:14:16 -07:00
)
2025-10-12 15:36:38 -07:00
self.theme = Path(__file__).parent / "themes" / self.config.THEME
2025-09-27 16:20:08 -07:00
2025-10-18 17:26:21 -07:00
self.web = Flask(self.config.NAME, template_folder=self.theme, static_folder=self.theme / "static")
self.web.config["SECRET_KEY"] = self.config.SECRET_KEY
self.web.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
2025-09-28 14:14:16 -07:00
self.web.config["DEBUG"] = True
2025-10-04 10:48:18 -07:00
self.web.config["SESSION_TYPE"] = "filesystem"
self.web.config["SESSION_REFRESH_EACH_REQUEST"] = True
self.web.config["SESSION_FILE_DIR"] = self.path.sessions
2025-10-12 15:36:38 -07:00
2025-10-04 10:48:18 -07:00
Session(self.web)
2025-10-22 19:20:47 -07:00
self.log = self.web.logger
2025-09-24 01:28:23 -07:00
self._initialized = True
2025-09-21 22:11:56 -07:00
def check_state(self) -> None:
if not self._initialized:
raise ApplicationNotInitializedError("This action requires the application to be initialized.")
2025-10-07 01:18:36 -07:00
def authenticate(self, username: str, password: str) -> schema.User:
"""
2025-10-07 01:18:36 -07:00
Returns the User record matching the given username and password
"""
2025-10-07 01:18:36 -07:00
if not (username and password):
2025-10-22 19:20:47 -07:00
self.log.debug("Need both username and password to login")
2025-10-07 01:18:36 -07:00
return None
2025-10-04 01:26:09 -07:00
2025-10-07 01:18:36 -07:00
user = self.db.User.get(where("name") == username)
if not user:
2025-10-22 19:20:47 -07:00
self.log.debug(f"No user matching {username}")
2025-10-07 01:18:36 -07:00
return None
2025-10-04 01:26:09 -07:00
2025-10-07 01:18:36 -07:00
if not user.check_credentials(username, password):
2025-10-22 19:20:47 -07:00
self.log.debug(f"Invalid credentials for {username}")
2025-10-07 01:18:36 -07:00
return None
2025-10-04 01:26:09 -07:00
2025-10-07 01:18:36 -07:00
return user
2025-10-05 00:15:37 -07:00
2025-10-07 01:18:36 -07:00
def authorize(self, user, record, requested):
return user.has_permission(record, requested)
2025-10-22 19:20:47 -07:00
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 = None
search_uri = uri
page_name = uri
2025-10-22 19:20:47 -07:00
if "/" in uri:
(parent_uri, page_name) = uri.rsplit("/", 1)
if parent_uri == 'Page':
parent_uri = None
2025-10-22 19:20:47 -07:00
search_uri = page_name
else:
search_uri = uri
# self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_uri=}")
2025-10-22 19:20:47 -07:00
# 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 parent_uri and "/" in parent_uri else self.db.Page
2025-10-22 19:20:47 -07:00
parent = None
try:
# self.log.debug(f"Loading parent with {parent_uri}")
2025-10-22 19:20:47 -07:00
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(f"Page with uri '{search_uri}' does not exist and neither does its parent.")
2025-10-22 19:20:47 -07:00
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}.")
obj = getattr(schema, table.name)
page = obj(name=page_name, body=obj.default.format(name=page_name), parent=parent)
# self.log.debug(f"Returning {page.doc_id}: {page.uri}")
2025-10-22 19:20:47 -07:00
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.")
page = self._get_or_create_page_by_uri(user, table, uri)
2025-10-22 19:20:47 -07:00
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
2025-09-21 22:11:56 -07:00
2025-09-24 01:28:23 -07:00
sys.modules[__name__] = ApplicationContext()