remarkbox/remarkbox/models/namespace.py
russell@unturf.com a5c3e36fce fix: Namespace.moderators returned all members; owners now imply mod
moderators property returned self.enabled_users (every enabled member),
so every user who had ever posted got moderator powers: spam
approve/deny, edit/delete other users' comments, moderator Slack
notifications. Bug was universal across all namespaces — any site where
users post was affected.

Fix: moderators now returns only users with role='moderator'.
Owners now implicitly pass is_moderator() checks, since NamespaceUser
is single-role per user per namespace; this keeps owners functional
without needing a data migration.
2026-04-21 19:59:40 -04:00

483 lines
16 KiB
Python

from sqlalchemy import (
Integer,
BigInteger,
Boolean,
Column,
Enum,
Unicode,
UnicodeText,
or_,
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.ext.associationproxy import association_proxy
import uuid
from collections import defaultdict
from .meta import Base, RBase
from .meta import UUIDType
from .meta import SUBSCRIPTION_TYPES
from .meta import now_timestamp, foreign_key
from .node import Node, get_nodes_who_share_roots_query
from .namespace_user import NamespaceUser
from .oauth import Oauth
from remarkbox.lib import timestamp_to_ago_string, timestamp_to_datetime
try:
unicode("")
except:
from six import u as unicode
# these attributes are protected such that they return the defaults
# when a Namespace is frozen or subscription is expired.
PROTECTED_ATTRIBUTES = {
"description": None,
"placeholder_text": None,
"no_nodes_text": None,
"theme": None,
"theme_embed": None,
"stylesheet": None,
"stylesheet_uri": None,
"stylesheet_embed": None,
"stylesheet_embed_uri": None,
"wiki": False,
"google_analytics_id": None,
"hide_unverified": False,
"hide_unless_approved": False,
"allow_anonymous": False,
"hide_powered_by": False,
"mathjax": False,
"link_protection": False,
"ignore_query_string": False,
"reverse_order": False,
"group_conversations": False,
"api_access": True,
"submit_button_text": None,
"comment_label_singular": None,
"comment_label_plural": None,
"max_nesting_depth": None,
"collapse_depth": None,
}
class Namespace(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
name = Column(Unicode(256), nullable=False, unique=True, index=True)
description = Column(Unicode(256), default=None)
# optional placeholder text for new node textarea.
placeholder_text = Column(Unicode(256), default=None, nullable=True)
# optional placeholder text when there are no nodes in thread.
no_nodes_text = Column(Unicode(256), default=None, nullable=True)
# the basic mode theme. TODO: change this column name to `theme_basic`.
theme = Column(Unicode(256), default=None)
# the embed mode theme.
theme_embed = Column(Unicode(256), default=None)
avatar_size = Column(Integer, default=None)
# TODO: change this to `stylesheet_basic` and `stylesheet_basic_timestamp`.
stylesheet = Column(UnicodeText, default=None)
stylesheet_timestamp = Column(BigInteger)
stylesheet_uri = Column(Unicode(256), default=None)
stylesheet_embed = Column(UnicodeText, default=None)
stylesheet_embed_timestamp = Column(BigInteger)
stylesheet_embed_uri = Column(Unicode(256), default=None)
# when was the last time we tested/verified the namespace owner request?
owner_request_timestamp = Column(BigInteger, nullable=True)
# optional google analytics id for stand alone mode.
google_analytics_id = Column(Unicode(18), nullable=True)
# optional google site verification code for stand alone mode.
google_site_verification = Column(Unicode(128), nullable=True)
# allow anyone to edit the root node. (not implemented yet)
wiki = Column(Boolean, default=False)
# by default we show all nodes but a Namespace can change this behavior.
# should a node be hidden if unverified by owner or moderator?
hide_unverified = Column(Boolean, default=False)
# should a node be hidden until approved by a moderator?
hide_unless_approved = Column(Boolean, default=False)
# allow anonymous commenting (name only, no email required)
allow_anonymous = Column(Boolean, default=False)
# should we hide the poweredby Remarkbox logo?
hide_powered_by = Column(Boolean, default=False)
# should the list of root nodes in this namespace be public or hidden?
public = Column(Boolean, default=False)
# should we enable MathJax?
mathjax = Column(Boolean, default=False)
# should we enable Link Protection to prevent comments from having links?
link_protection = Column(Boolean, default=False)
# check this box if the external site does _not_ use
# query strings for page identification or navigation.
ignore_query_string = Column(Boolean, default=False)
# should we reverse the node order and put newest on top?
reverse_order = Column(Boolean, default=False)
# should we group conversations and limit to nesting 2 deep?
group_conversations = Column(Boolean, default=False)
# allow JSON API access to this namespace?
api_access = Column(Boolean, default=True)
# the group postfix used for imports (e.g., "rb" creates "Anonymous-rb")
# Once set, this becomes permanent for all imported surrogates
import_group_postfix = Column(Unicode(6), default=None, nullable=True)
# T4: customizable button text and comment labels
submit_button_text = Column(Unicode(256), default=None, nullable=True)
comment_label_singular = Column(Unicode(256), default=None, nullable=True)
comment_label_plural = Column(Unicode(256), default=None, nullable=True)
# T8: nesting depth settings
max_nesting_depth = Column(Integer, default=None, nullable=True)
collapse_depth = Column(Integer, default=None, nullable=True)
# Spam filter: namespace owners can disable Hermes LLM checks.
spam_filter_enabled = Column(Boolean, default=True)
# the type of subscription of this Namespace.
subscription_type = Column(
Enum(*SUBSCRIPTION_TYPES, name="subscription_type"),
# Hooray, Remarkbox becomes pay-what-you-can!
# https://en.wikipedia.org/wiki/Pay_what_you_can
default="production",
nullable=False,
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
roots = relationship(argument="Node", lazy="dynamic", back_populates="namespace")
users = association_proxy(
"namespace_users", "user", creator=lambda u: NamespaceUser(user=u)
)
user_surrogates = relationship(
argument="UserSurrogate", lazy="dynamic", back_populates="namespace"
)
namespace_owner_requests = relationship(
argument="NamespaceRequest", lazy="dynamic", back_populates="namespace"
)
oauth_records = relationship(
argument="Oauth", lazy="dynamic", back_populates="namespace"
)
watchers = relationship(
argument="Watcher", lazy="dynamic", back_populates="namespace"
)
@property
def powered_up(self):
if self.subscription_type in ["production", "trial"]:
return True
return False
def reload_memoized_attr_protection(self):
"""If True, protect attributes from access."""
if self.powered_up:
# do not protect attributes, the namespace is powered up.
self.memoized_attr_protection = False
else:
# protect attributes, the namespace is not powered up.
self.memoized_attr_protection = True
def __getattribute__(self, attr):
"""Protect certain attributes."""
# return early if attribute isn't protected.
if attr not in PROTECTED_ATTRIBUTES:
return object.__getattribute__(self, attr)
if not hasattr(self, "memoized_attr_protection"):
self.reload_memoized_attr_protection()
if self.memoized_attr_protection:
# attributes currently under protection.
# load and return the attribute's default value.
return PROTECTED_ATTRIBUTES[attr]
# attributes currently not under protection.
# load and return the attribute's real value.
return object.__getattribute__(self, attr)
def __init__(self, name=None):
self.id = uuid.uuid1()
self.name = unicode(name)
def add_oauth_record(self, user, service, token, data=None):
self.oauth_records.append(
Oauth(user=user, namespace=self, service=service, token=token, data=data)
)
@property
def slack_oauth_records(self):
return [oauth for oauth in self.oauth_records if oauth.service == "slack"]
def get_namespace_user_for_user(self, user):
"""Given a User object, return the NamespaceUser object."""
for nsu in self.namespace_users:
if nsu.user == user:
return nsu
def set_role_for_user(self, user, role=None):
"""Add User object to NamespaceUser. Default role is moderator."""
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
nsu.role = role
else:
nsu = NamespaceUser(role=role, namespace=self, user=user)
self.namespace_users.append(nsu)
user.watch_namespace(self)
def disable_user(self, user):
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
user.unwatch_namespace(self)
nsu.disabled = True
def enable_user(self, user):
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
user.watch_namespace(self)
nsu.disabled = None
def remove_user(self, user):
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
self.disable_user(user)
self.dbsession.delete(nsu)
@property
def enabled_namespace_users(self):
"""Return not disabled NamespaceUser objects for this Namespace."""
return [nsu for nsu in self.namespace_users if not nsu.disabled]
@property
def enabled_users(self):
"""Return not disabled User objects for this Namespace."""
return [nsu.user for nsu in self.enabled_namespace_users]
@property
def roles(self):
"""Return dict with role name as key and list of Users as value."""
roles = defaultdict(list)
for ns_user in self.enabled_namespace_users:
roles[ns_user.role].append(ns_user.user)
return roles
@property
def owners(self):
"""Return a list of owner role User objects."""
return self.roles.get("owner", [])
@property
def moderators(self):
"""Return a list of moderator role User objects."""
return self.roles.get("moderator", [])
@property
def visible_roots(self):
return self.roots.filter(
Node.verified == True, Node.disabled == False
).order_by(Node.changed.desc())
@property
def visible_root_stats(self):
stats = {}
for root in self.visible_roots:
if root.title:
stats[root.path] = root.stats
elif root.uri:
stats[root.uri.data] = root.stats
return stats
@property
def nodes(self):
return (
get_nodes_who_share_roots_query(self.dbsession, self.roots)
.filter(Node.parent_id.isnot(None))
.order_by(Node.changed.desc())
)
def page_nodes(self, limit=100, offset=0):
if self.roots.count() == 0:
return []
query = get_nodes_who_share_roots_query(self.dbsession, self.roots).filter(
Node.disabled == False, Node.user_id != None
)
if self.hide_unless_approved:
query = query.filter(Node.approved == True)
if self.hide_unverified:
query = query.filter(Node.verified == True)
return query.order_by(Node.changed.desc()).limit(limit).offset(offset)
@property
def verified_nodes(self):
return self.nodes.filter(Node.verified == True)
@property
def disabled_nodes(self):
return self.nodes.filter(Node.disabled == True)
@property
def approved_nodes(self):
return self.nodes.filter(Node.approved == True, Node.disabled == False)
@property
def unapproved_nodes(self):
return self.nodes.filter(
or_(Node.approved == False, Node.approved.is_(None)), Node.disabled == False
)
@property
def spam_nodes(self):
return self.nodes.filter(
Node.spam_score >= 0.5, Node.disabled == False
)
@property
def visible_nodes(self):
return get_nodes_who_share_roots_query(self.dbsession, self.visible_roots)
@property
def owner_request_pending(self):
"""Is this Namespace due for a owner request verification?"""
if self.owner_request_timestamp:
delta = now_timestamp() - self.owner_request_timestamp
# 5 minutes.
if delta <= 300:
return False
return True
@property
def node_order(self):
if self.reverse_order:
return "newest-first"
return "oldest-first"
def update_owner_request_timestamp(self):
self.owner_request_timestamp = now_timestamp()
def clear_owner_request_timestamp(self):
self.owner_request_timestamp = None
def set_stylesheet(self, body):
self.stylesheet = body
self.stylesheet_timestamp = now_timestamp()
def set_stylesheet_embed(self, body):
self.stylesheet_embed = body
self.stylesheet_embed_timestamp = now_timestamp()
def is_owner(self, user):
"""Return True if given user owns this namespace, else False."""
if user and user.authenticated and user in self.owners:
return True
return False
def is_moderator(self, user):
"""Return True if given user moderates this namespace, else False.
Superusers are treated as moderators on every namespace.
Owners are implicitly moderators.
"""
if user and user.authenticated:
if getattr(user, "is_superuser", False):
return True
if user in self.moderators:
return True
if user in self.owners:
return True
return False
def can_alter_node(self, node, user):
return self.is_moderator(user) or node.is_owner(user)
def can_wiki_edit(self, node, user):
"""Return True if user can wiki-edit this node.
Wiki mode: any authenticated user can edit root nodes.
Normal mode: only owner/moderator can edit.
"""
if not user or not user.authenticated:
return False
if self.can_alter_node(node, user):
return True
if self.wiki and node.is_root:
return True
return False
def can_see_node(self, node, user):
visible = True
if node.disabled:
visible = False
if self.hide_unless_approved and not node.approved:
visible = False
if self.hide_unverified and not node.verified:
visible = False
if user:
if self.is_moderator(user) or node.is_owner(user):
# owners can see node.
visible = True
return visible
@property
def dict_dump(self):
dump = {}
for root in self.roots:
if root.has_uri:
root_identifier = root.uri.data
else:
root_identifier = root.slug
root_dump = {
"name" : root_identifier,
"title" : root.title,
# TODO: fix when root.created becomes root.created_timestamp.
"timestamp" : root.created,
}
comments = []
# Limit children per root to prevent unbounded JSON serialization (CWE-407).
for node in root.children.limit(500):
comment = {
"date" : node.created_date,
# TODO: when root.created becomes root.created_timestamp.
"timestamp" : node.created,
"content" : node.data,
"author" : node.user.name,
"author_ip" : node.ip_address,
}
if self.subscription_type == "production":
comment["email"] = node.user.email
comments.append(comment)
root_dump["comments"] = comments
dump[root_identifier] = root_dump
return dump
def get_topsecret_namespaces(dbsession):
return dbsession.query(Namespace).all()
def get_namespace_by_name(dbsession, name):
if name:
return (
dbsession.query(Namespace)
.filter(Namespace.name == unicode(name))
.one_or_none()
)
def get_or_create_namespace(dbsession, name):
namespace = get_namespace_by_name(dbsession, name)
if not namespace:
namespace = Namespace(name)
# commit namespace to db right away to prevent detached instances.
# causes some trash namespaces to accumulate but better then errors.
dbsession.add(namespace)
dbsession.flush()
return namespace