2025-10-05 00:15:37 -07:00
|
|
|
from __future__ import annotations
|
2025-09-24 01:28:23 -07:00
|
|
|
|
2025-11-01 23:48:56 -07:00
|
|
|
import re
|
2025-10-05 00:15:37 -07:00
|
|
|
from datetime import datetime
|
2025-10-08 00:46:09 -07:00
|
|
|
from enum import StrEnum
|
2025-11-01 23:48:56 -07:00
|
|
|
from functools import cached_property
|
2025-10-29 19:06:57 -07:00
|
|
|
from textwrap import dedent
|
2025-10-30 20:52:15 -07:00
|
|
|
from typing import List
|
2025-10-05 00:15:37 -07:00
|
|
|
|
2025-11-01 23:48:56 -07:00
|
|
|
from flask import g
|
|
|
|
|
from grung.objects import (
|
2025-10-18 17:26:21 -07:00
|
|
|
BackReference,
|
|
|
|
|
Collection,
|
|
|
|
|
DateTime,
|
|
|
|
|
Dict,
|
|
|
|
|
Field,
|
|
|
|
|
Password,
|
|
|
|
|
Pointer,
|
|
|
|
|
Record,
|
|
|
|
|
TextFilePointer,
|
|
|
|
|
Timestamp,
|
|
|
|
|
)
|
2025-11-01 23:48:56 -07:00
|
|
|
from grung.validators import PatternValidator
|
2025-10-07 01:18:36 -07:00
|
|
|
from tinydb import where
|
2025-09-25 22:31:37 -07:00
|
|
|
|
2025-11-01 23:48:56 -07:00
|
|
|
from ttfrog.exceptions import MalformedRequestError
|
|
|
|
|
|
2025-09-25 22:31:37 -07:00
|
|
|
|
2025-10-30 20:51:24 -07:00
|
|
|
def app_context():
|
|
|
|
|
import ttfrog.app
|
|
|
|
|
|
|
|
|
|
ttfrog.app.check_state()
|
|
|
|
|
return ttfrog.app
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 23:48:56 -07:00
|
|
|
READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference, Timestamp]
|
|
|
|
|
|
|
|
|
|
|
2025-10-08 00:46:09 -07:00
|
|
|
class Permissions(StrEnum):
|
|
|
|
|
READ = "r"
|
|
|
|
|
WRITE = "w"
|
|
|
|
|
DELETE = "d"
|
|
|
|
|
|
|
|
|
|
|
2025-09-25 22:31:37 -07:00
|
|
|
class Page(Record):
|
2025-10-04 01:26:09 -07:00
|
|
|
"""
|
|
|
|
|
A page in the wiki. Just about everything in the databse is either a Page or a subclass of a Page.
|
|
|
|
|
"""
|
2025-10-30 20:52:15 -07:00
|
|
|
|
|
|
|
|
default = dedent(
|
|
|
|
|
"""
|
2025-10-29 19:06:57 -07:00
|
|
|
# {name}
|
|
|
|
|
|
|
|
|
|
*Overview of this page*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Section 1
|
|
|
|
|
|
|
|
|
|
*Organize your text into logically separted sections.*
|
|
|
|
|
|
2025-10-30 20:52:15 -07:00
|
|
|
"""
|
|
|
|
|
)
|
2025-10-04 01:26:09 -07:00
|
|
|
|
2025-09-28 14:14:16 -07:00
|
|
|
@classmethod
|
|
|
|
|
def fields(cls):
|
2025-10-07 01:18:36 -07:00
|
|
|
# fmt: off
|
2025-09-28 14:14:16 -07:00
|
|
|
return [
|
2025-10-08 00:46:09 -07:00
|
|
|
*super().fields(),
|
2025-11-01 23:48:56 -07:00
|
|
|
|
|
|
|
|
# The URI for the page, relative to the app's VIEW_URI
|
|
|
|
|
Field("uri", unique=True, validators=[
|
|
|
|
|
PatternValidator(re.compile(
|
|
|
|
|
r"""
|
|
|
|
|
^ # match from beginning of line
|
|
|
|
|
(?:
|
|
|
|
|
/ # first char can be a /
|
|
|
|
|
| # or
|
|
|
|
|
(?:
|
|
|
|
|
[0-9a-z] # first char is alphanum
|
|
|
|
|
(?:/?[0-9a-z]+)* # following by more alphnum, maybe with a leading /
|
|
|
|
|
)+ # at least once, possibly many times,
|
|
|
|
|
)? # or URI could be entirely blank
|
|
|
|
|
$ # until the end of the string
|
|
|
|
|
""",
|
|
|
|
|
re.IGNORECASE | re.VERBOSE
|
|
|
|
|
))
|
|
|
|
|
]),
|
|
|
|
|
|
|
|
|
|
# The portion of the URI after the last /
|
|
|
|
|
Field("name", validators=[PatternValidator(re.compile(r'^(?:/|(?:[0-9a-z]+))$', re.IGNORECASE))]),
|
|
|
|
|
|
|
|
|
|
# The pages that exist below this page's URI
|
|
|
|
|
Collection("members", member_type=Page),
|
|
|
|
|
|
|
|
|
|
# The last user to touch the page.
|
|
|
|
|
Pointer("author", value_type=User),
|
|
|
|
|
|
|
|
|
|
# When the page was created
|
|
|
|
|
DateTime("created"),
|
|
|
|
|
|
|
|
|
|
# The last time the page was modified.
|
|
|
|
|
Timestamp("last_modified"),
|
|
|
|
|
|
|
|
|
|
# The main content blob of the page
|
|
|
|
|
TextFilePointer("body", extension='.md', default=Page.default),
|
|
|
|
|
|
|
|
|
|
# The access control list for this Page.
|
2025-10-07 23:30:23 -07:00
|
|
|
Dict("acl"),
|
2025-09-28 14:14:16 -07:00
|
|
|
]
|
2025-10-29 19:06:57 -07:00
|
|
|
# fmt: on
|
|
|
|
|
|
2025-11-01 23:48:56 -07:00
|
|
|
@cached_property
|
|
|
|
|
def read_only(self) -> set:
|
|
|
|
|
return [name for (name, attr) in self._metadata.fields.items() if type(attr) in READ_ONLY_FIELD_TYPES] + [
|
|
|
|
|
"uid",
|
|
|
|
|
"acl",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def update(self, **kwargs):
|
|
|
|
|
super().update(**dict((key, value) for key, value in kwargs.items() if key not in self.read_only))
|
|
|
|
|
|
2025-10-30 20:51:24 -07:00
|
|
|
def parent(self):
|
2025-10-29 19:06:57 -07:00
|
|
|
if self.uri == "":
|
|
|
|
|
return None
|
|
|
|
|
|
2025-10-30 20:51:24 -07:00
|
|
|
app = app_context()
|
|
|
|
|
|
2025-10-29 19:06:57 -07:00
|
|
|
parent_uri = ""
|
2025-11-01 23:48:56 -07:00
|
|
|
if self.uri and "/" in self.uri:
|
2025-10-29 19:06:57 -07:00
|
|
|
parent_uri = self.uri.rsplit("/", 1)[0]
|
2025-10-30 20:51:24 -07:00
|
|
|
for table_name in app.db.tables():
|
|
|
|
|
page = app.db.table(table_name).get(where("uri") == parent_uri, recurse=False)
|
2025-10-29 19:06:57 -07:00
|
|
|
if page:
|
|
|
|
|
return page
|
2025-09-28 14:14:16 -07:00
|
|
|
|
2025-10-30 20:51:24 -07:00
|
|
|
def before_insert(self, *args, **kwargs):
|
2025-10-04 01:26:09 -07:00
|
|
|
"""
|
|
|
|
|
Make the following adjustments before saving this record:
|
|
|
|
|
* Derive the URI from the hierarchy of the parent.
|
|
|
|
|
"""
|
2025-10-18 17:26:21 -07:00
|
|
|
if not self.name:
|
|
|
|
|
raise Exception("Must provide a name")
|
|
|
|
|
|
2025-10-30 20:51:24 -07:00
|
|
|
app = app_context()
|
|
|
|
|
|
|
|
|
|
super().before_insert(app.db)
|
|
|
|
|
|
|
|
|
|
if not self.author:
|
2025-11-01 23:48:56 -07:00
|
|
|
self.author = app.db.User.get(where("name") == "__system__")
|
2025-10-03 16:44:25 -07:00
|
|
|
|
2025-10-05 00:15:37 -07:00
|
|
|
now = datetime.utcnow()
|
|
|
|
|
if not self.doc_id and self.created < now:
|
|
|
|
|
self.created = now
|
|
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
def add_member(self, child: Record):
|
2025-10-30 20:51:24 -07:00
|
|
|
app = app_context()
|
2025-10-07 01:18:36 -07:00
|
|
|
|
2025-10-29 19:06:57 -07:00
|
|
|
prefix = (self.uri + "/") if self.uri else ""
|
|
|
|
|
new_uri = f"{prefix}{child.name}"
|
|
|
|
|
if child.uri != new_uri:
|
|
|
|
|
app.log.debug(f"Moving {child._metadata.table}[{child.doc_id}] from {child.uri} to {new_uri}")
|
|
|
|
|
child.uri = new_uri
|
2025-10-07 01:18:36 -07:00
|
|
|
self.members = list(set(self.members + [app.db.save(child)]))
|
|
|
|
|
app.db.save(self)
|
|
|
|
|
return self.get_child(child)
|
|
|
|
|
|
2025-10-03 16:44:25 -07:00
|
|
|
def get_child(self, obj: Record):
|
2025-10-04 01:26:09 -07:00
|
|
|
for page in self.members:
|
2025-10-08 00:46:09 -07:00
|
|
|
if page[page._metadata.primary_key] == obj[obj._metadata.primary_key]:
|
2025-10-03 16:44:25 -07:00
|
|
|
return page
|
|
|
|
|
return None
|
2025-10-04 01:26:09 -07:00
|
|
|
|
2025-10-07 23:30:23 -07:00
|
|
|
def set_permissions(self, entity: Entity, permissions: List) -> str:
|
2025-10-30 20:51:24 -07:00
|
|
|
app = app_context()
|
2025-10-07 01:18:36 -07:00
|
|
|
|
2025-10-07 23:30:23 -07:00
|
|
|
perms = "".join(permissions)
|
|
|
|
|
self.acl[entity.reference] = perms
|
2025-10-07 01:18:36 -07:00
|
|
|
app.db.save(self)
|
2025-10-05 00:15:37 -07:00
|
|
|
return perms
|
|
|
|
|
|
2025-10-07 23:30:23 -07:00
|
|
|
def get_acl_for_entity(self, entity) -> (str, str | None):
|
2025-10-07 01:18:36 -07:00
|
|
|
"""
|
|
|
|
|
Search upward through the page hierarchy looking for one with an ACL that either
|
|
|
|
|
has a grant for the entity we care about, or at least one group in which the entity is a member.
|
|
|
|
|
"""
|
2025-10-30 20:51:24 -07:00
|
|
|
app = app_context()
|
2025-10-06 17:50:10 -07:00
|
|
|
|
|
|
|
|
def find_acl(obj):
|
2025-10-07 01:18:36 -07:00
|
|
|
if hasattr(obj, "acl"):
|
2025-10-07 23:30:23 -07:00
|
|
|
if entity.reference in obj.acl:
|
|
|
|
|
return {entity.reference: obj.acl[entity.reference]}
|
|
|
|
|
|
|
|
|
|
group_grants = {}
|
|
|
|
|
for ref, grant in obj.acl.items():
|
2025-10-08 00:46:09 -07:00
|
|
|
(table_name, pkey, pval) = ref.split("::")
|
|
|
|
|
if table_name == "Group":
|
|
|
|
|
group = app.db.Group.get(where(pkey) == pval, recurse=False)
|
2025-10-07 23:30:23 -07:00
|
|
|
if entity.reference in group.members:
|
|
|
|
|
group_grants[ref] = grant
|
2025-10-07 01:18:36 -07:00
|
|
|
if group_grants:
|
|
|
|
|
return group_grants
|
|
|
|
|
|
2025-10-07 23:30:23 -07:00
|
|
|
if hasattr(obj, "parent"):
|
2025-10-30 20:51:24 -07:00
|
|
|
return find_acl(obj.parent())
|
2025-10-07 23:30:23 -07:00
|
|
|
return {"": ""}
|
2025-10-06 17:50:10 -07:00
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
return find_acl(self)
|
2025-10-05 00:15:37 -07:00
|
|
|
|
|
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
class Entity(Page):
|
2025-10-08 00:46:09 -07:00
|
|
|
@classmethod
|
|
|
|
|
def fields(cls):
|
2025-10-18 17:26:21 -07:00
|
|
|
inherited = [field for field in super().fields() if field.name not in ("members", "uid")]
|
2025-10-08 00:46:09 -07:00
|
|
|
return inherited + [
|
|
|
|
|
Field("name", primary_key=True),
|
|
|
|
|
]
|
|
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
def has_permission(self, record: Record, requested: str) -> bool | None:
|
2025-10-07 23:30:23 -07:00
|
|
|
for entity, grants in record.get_acl_for_entity(self).items():
|
|
|
|
|
if requested in grants:
|
2025-10-05 00:15:37 -07:00
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
def can_read(self, record: Record):
|
|
|
|
|
return self.has_permission(record, Permissions.READ)
|
2025-10-05 00:15:37 -07:00
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
def can_write(self, record: Record):
|
|
|
|
|
return self.has_permission(record, Permissions.WRITE)
|
2025-10-04 01:26:09 -07:00
|
|
|
|
2025-10-07 01:18:36 -07:00
|
|
|
def can_delete(self, record: Record):
|
|
|
|
|
return self.has_permission(record, Permissions.DELETE)
|
2025-10-05 00:15:37 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class User(Entity):
|
2025-10-04 01:26:09 -07:00
|
|
|
"""
|
|
|
|
|
A website user, editable as a wiki page.
|
|
|
|
|
"""
|
2025-10-07 01:18:36 -07:00
|
|
|
|
2025-10-04 01:26:09 -07:00
|
|
|
@classmethod
|
|
|
|
|
def fields(cls):
|
2025-10-08 00:46:09 -07:00
|
|
|
return super().fields() + [
|
|
|
|
|
Field("email", unique=True),
|
|
|
|
|
Password("password"),
|
2025-10-04 01:26:09 -07:00
|
|
|
]
|
|
|
|
|
|
2025-11-01 23:48:56 -07:00
|
|
|
def update(self, **data):
|
|
|
|
|
self.author = None if self == g.user else g.user
|
|
|
|
|
return super().update(**data)
|
|
|
|
|
|
|
|
|
|
def validate(self):
|
|
|
|
|
if self.name == "__system__":
|
|
|
|
|
raise MalformedRequestError("Invalid name.")
|
|
|
|
|
return super().validate()
|
|
|
|
|
|
2025-10-08 00:46:09 -07:00
|
|
|
def check_credentials(self, username: str, password: str) -> bool:
|
|
|
|
|
return username == self.name and self._metadata.fields["password"].compare(password, self.password)
|
|
|
|
|
|
2025-10-30 20:51:24 -07:00
|
|
|
def after_insert(self, *args, **kwargs):
|
2025-10-29 19:06:57 -07:00
|
|
|
"""
|
|
|
|
|
After saving this record, ensure that any page in the members collection is updated with the
|
|
|
|
|
correct URI. This ensures that if a page is moved from one collection to another, the URI is updated.
|
|
|
|
|
"""
|
2025-10-30 20:51:24 -07:00
|
|
|
app = app_context()
|
|
|
|
|
|
|
|
|
|
super().after_insert(app.db)
|
2025-10-29 19:06:57 -07:00
|
|
|
if not hasattr(self, "members"):
|
|
|
|
|
return
|
|
|
|
|
for child in self.members:
|
2025-10-30 20:51:24 -07:00
|
|
|
obj = BackReference.dereference(child, app.db, recurse=False)
|
2025-10-29 19:06:57 -07:00
|
|
|
obj.uri = f"{self.uri}/{obj.name}"
|
2025-10-30 20:51:24 -07:00
|
|
|
child = app.db.save(obj)
|
2025-10-29 19:06:57 -07:00
|
|
|
|
2025-10-04 01:26:09 -07:00
|
|
|
|
2025-10-05 00:15:37 -07:00
|
|
|
class Group(Entity):
|
2025-10-04 01:26:09 -07:00
|
|
|
"""
|
|
|
|
|
A set of users, editable as a wiki page.
|
|
|
|
|
"""
|
2025-10-18 17:26:21 -07:00
|
|
|
|
2025-10-08 00:46:09 -07:00
|
|
|
@classmethod
|
|
|
|
|
def fields(cls):
|
2025-11-01 23:48:56 -07:00
|
|
|
return super().fields() + [Collection("members", member_type=Entity)]
|
2025-10-04 01:26:09 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class NPC(Page):
|
|
|
|
|
"""
|
|
|
|
|
An NPC, editable as a wiki page.
|
|
|
|
|
"""
|
2025-10-30 20:52:15 -07:00
|
|
|
|
|
|
|
|
default = dedent(
|
|
|
|
|
"""
|
2025-10-29 19:06:57 -07:00
|
|
|
# {name}
|
|
|
|
|
*[Ancestry] [Class]*
|
|
|
|
|
|
|
|
|
|
| AC | HP | STR | DEX | CON | INT | WIS | CHA
|
|
|
|
|
|----|----|-----|-----|-----|-----|-----|------
|
|
|
|
|
| 10 | 10 | +0 | +0 | +0 | +0 | +0 | +0
|
|
|
|
|
|
|
|
|
|
**{name} (they/they)** [description]
|
|
|
|
|
|
|
|
|
|
* Personality: **[keywords]**
|
|
|
|
|
* Flaw: **[flaw]**
|
|
|
|
|
* Goal: **[goal]**
|
|
|
|
|
|
2025-10-30 20:52:15 -07:00
|
|
|
"""
|
|
|
|
|
)
|