diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py
index 976f343..5c711d7 100644
--- a/remarkbox/models/namespace.py
+++ b/remarkbox/models/namespace.py
@@ -52,6 +52,7 @@ PROTECTED_ATTRIBUTES = {
"google_analytics_id": None,
"hide_unverified": False,
"hide_unless_approved": False,
+ "allow_anonymous": False,
"hide_powered_by": False,
"mathjax": False,
"link_protection": False,
@@ -92,6 +93,8 @@ class Namespace(RBase, Base):
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?
diff --git a/remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py b/remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py
new file mode 100644
index 0000000..d24feab
--- /dev/null
+++ b/remarkbox/scripts/alembic/versions/108519de76ac_add_allow_anonymous_column_to_namespace.py
@@ -0,0 +1,24 @@
+"""Add allow_anonymous column to namespace
+
+Revision ID: 108519de76ac
+Revises: 5188e62d0afb
+Create Date: 2025-12-20 11:05:36.134829
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '108519de76ac'
+down_revision = '5188e62d0afb'
+branch_labels = None
+depends_on = None
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.add_column('rb_namespace', sa.Column('allow_anonymous', sa.Boolean(), nullable=True, server_default='0'))
+
+
+def downgrade():
+ op.drop_column('rb_namespace', 'allow_anonymous')
diff --git a/remarkbox/templates/namespace-settings.j2 b/remarkbox/templates/namespace-settings.j2
index 5645cf0..c4e1234 100644
--- a/remarkbox/templates/namespace-settings.j2
+++ b/remarkbox/templates/namespace-settings.j2
@@ -39,6 +39,11 @@
If checked, hide new comments until approved by a moderator.
+
+
+If checked, allow comments without email verification. Commenters enter a display name only. They cannot edit comments or receive reply notifications.
+
+
If checked, replace all links in comments with [link removed].
diff --git a/remarkbox/templates/snippets/email.j2 b/remarkbox/templates/snippets/email.j2
index 8b1cc1a..c6d1c6a 100644
--- a/remarkbox/templates/snippets/email.j2
+++ b/remarkbox/templates/snippets/email.j2
@@ -1,4 +1,16 @@
{% if not request.user.authenticated %}
+ {% if request.namespace.allow_anonymous %}
+ {# Anonymous mode: show name field instead of email #}
+
+ {% else %}
+ {# Normal mode: require email #}
- {% if show_whats_next %}
+ {% if show_whats_next %}
What's next? check your email to log in!
- {% endif %}
+ {% endif %}
- {% if show_whats_next_notifications %}
+ {% if show_whats_next_notifications %}
What's next? verify your email address for reply notifications!
+ {% endif %}
{% endif %}
-
{% endif %}
diff --git a/remarkbox/views/authenticated/authenticated.py b/remarkbox/views/authenticated/authenticated.py
index 39067e4..1685612 100644
--- a/remarkbox/views/authenticated/authenticated.py
+++ b/remarkbox/views/authenticated/authenticated.py
@@ -60,6 +60,7 @@ def namespace_settings(request):
)
hide_unless_approved_checkbox = p.get("hide-unless-approved-checkbox", "off")
+ allow_anonymous_checkbox = p.get("allow-anonymous-checkbox", "off")
link_protection_checkbox = p.get("link-protection-checkbox", "off")
reverse_order_checkbox = p.get("reverse-order-checkbox", "off")
group_conversations_checkbox = p.get("group-conversations-checkbox", "off")
@@ -68,6 +69,7 @@ def namespace_settings(request):
hide_powered_by_checkbox = p.get("hide-powered-by-checkbox", "off")
hide_unless_approved = checkbox_to_bool(hide_unless_approved_checkbox)
+ allow_anonymous = checkbox_to_bool(allow_anonymous_checkbox)
link_protection = checkbox_to_bool(link_protection_checkbox)
reverse_order = checkbox_to_bool(reverse_order_checkbox)
group_conversations = checkbox_to_bool(group_conversations_checkbox)
@@ -146,6 +148,17 @@ def namespace_settings(request):
)
)
+ if allow_anonymous != request.namespace.allow_anonymous:
+ request.namespace.allow_anonymous = allow_anonymous
+ request.session.flash(
+ (
+ "You turned {} allow_anonymous".format(
+ allow_anonymous_checkbox
+ ),
+ "success",
+ )
+ )
+
if link_protection != request.namespace.link_protection:
request.namespace.link_protection = link_protection
request.session.flash(
diff --git a/remarkbox/views/new_thread.py b/remarkbox/views/new_thread.py
index d7c9a2c..315b6c9 100644
--- a/remarkbox/views/new_thread.py
+++ b/remarkbox/views/new_thread.py
@@ -4,7 +4,7 @@ from pyramid.csrf import check_csrf_token
from pyramid.httpexceptions import HTTPFound
-from remarkbox.models import create_root_node
+from remarkbox.models import create_root_node, get_or_create_user_surrogate_by_name
from . import get_referer_or_home, get_node_route_uri, set_node_to_pending_in_session
@@ -21,6 +21,7 @@ def new_thread(request):
"""Display new page and handle posting of form."""
thread_title = request.params.get("thread_title", "")
thread_data = request.params.get("thread_data", "")
+ anonymous_name = request.params.get("anonymous_name", "").strip()
if request.spam:
return request.spam
@@ -32,59 +33,76 @@ def new_thread(request):
if thread_title and thread_data:
# handle the submitted form new/create form.
- if request.user is None:
+ # Handle anonymous mode vs regular mode
+ user_surrogate = None
+ if request.namespace.allow_anonymous and not request.user:
+ # Anonymous mode: create or get a surrogate
+ if not anonymous_name:
+ anonymous_name = "Anonymous"
+ user_surrogate = get_or_create_user_surrogate_by_name(
+ request.dbsession, anonymous_name, request.namespace
+ )
+ elif request.user is None:
+ # Regular mode: require email/user
request.session.flash(
("Press the back button to fix your email address", "error")
)
return HTTPFound(get_referer_or_home(request))
- else:
- # create a new root node.
- node = create_root_node()
+ # create a new root node.
+ node = create_root_node()
+ node.namespace = request.namespace
+ node.ip_address = unicode(request.client_addr)
+ node.title = thread_title
+ node.set_data(thread_data)
+ # Handle anonymous vs authenticated user
+ if user_surrogate:
+ # Anonymous mode: attach surrogate, mark as verified
+ node.user_surrogate = user_surrogate
+ node.verified = True
+ node_event = None # No notifications for anonymous posts
+ request.dbsession.add(user_surrogate)
+ else:
+ # Normal mode: attach user
node.user = request.user
node.verified = request.user.authenticated
- node.namespace = request.namespace
- node.ip_address = unicode(request.client_addr)
-
- node.title = thread_title
- node.set_data(thread_data)
-
node_event = node.new_event(request.user, "created")
-
- request.dbsession.add(node)
- request.dbsession.add(node_event)
request.dbsession.add(request.user)
- request.dbsession.add(node.namespace)
- request.dbsession.flush()
+ request.dbsession.add(node)
+ if node_event:
+ request.dbsession.add(node_event)
+ request.dbsession.add(node.namespace)
+ request.dbsession.flush()
+
+ if node_event:
# TODO: schedule_notification expects the request to have a node.
request.node = node
-
schedule_notifications(request, node_event)
- msg = ("Your post was successful!", "success")
- request.session.flash(msg)
+ msg = ("Your post was successful!", "success")
+ request.session.flash(msg)
- # set return_to to the node's URI.
- return_to = get_node_route_uri(request, node)
+ # set return_to to the node's URI.
+ return_to = get_node_route_uri(request, node)
- if node.verified == True:
- # Redirect to new node if verified.
- return HTTPFound(return_to)
+ # Anonymous users are always verified, redirect immediately
+ if user_surrogate or node.verified:
+ return HTTPFound(return_to)
- set_node_to_pending_in_session(request, node)
+ set_node_to_pending_in_session(request, node)
- # Redirect to join-or-log-in, posting email and submit.
- uri = request.route_url(
- route_name="basic-join-or-log-in",
- _query={
- "email": request.user.email,
- "return-to": return_to,
- "submit": True,
- },
- )
- return HTTPFound(uri)
+ # Redirect to join-or-log-in, posting email and submit.
+ uri = request.route_url(
+ route_name="basic-join-or-log-in",
+ _query={
+ "email": request.user.email,
+ "return-to": return_to,
+ "submit": True,
+ },
+ )
+ return HTTPFound(uri)
return {
"title": "Create a new thread",
diff --git a/remarkbox/views/reply_node.py b/remarkbox/views/reply_node.py
index 80767d1..9fba08f 100644
--- a/remarkbox/views/reply_node.py
+++ b/remarkbox/views/reply_node.py
@@ -12,6 +12,7 @@ from . import (
)
from remarkbox.lib.notify import schedule_notifications
+from remarkbox.models import get_or_create_user_surrogate_by_name
try:
unicode("")
@@ -27,6 +28,7 @@ except:
def reply_node(request):
"""handle posting of reply form from show-node pages."""
thread_data = request.params.get("thread_data", "")
+ anonymous_name = request.params.get("anonymous_name", "").strip()
# return early if spam attribute is truthy.
if request.spam:
@@ -43,8 +45,17 @@ def reply_node(request):
request.session.flash(("No remarks for the disabled.", "error"))
return HTTPFound(get_referer_or_home(request))
- # flash error and return early if user is None.
- if request.user is None:
+ # Handle anonymous mode vs regular mode
+ user_surrogate = None
+ if request.namespace.allow_anonymous and not request.user:
+ # Anonymous mode: create or get a surrogate
+ if not anonymous_name:
+ anonymous_name = "Anonymous"
+ user_surrogate = get_or_create_user_surrogate_by_name(
+ request.dbsession, anonymous_name, request.namespace
+ )
+ elif request.user is None:
+ # Regular mode: require email/user
request.session.flash(
("Press the back button to fix your email address", "error")
)
@@ -72,16 +83,30 @@ def reply_node(request):
# STEP 2: attach a brand new child node to parent node.
child = parent.new_child()
- child.user = request.user
child.ip_address = unicode(request.client_addr)
- child.verified = request.user.authenticated
child.set_data(thread_data, namespace=request.namespace)
- child_event = child.new_event(request.user, "commented")
+
+ # Handle anonymous vs authenticated user
+ if user_surrogate:
+ # Anonymous mode: attach surrogate, mark as verified (no email to verify)
+ child.user_surrogate = user_surrogate
+ child.verified = True
+ child_event = None # No notifications for anonymous comments
+ request.dbsession.add(user_surrogate)
+ else:
+ # Normal mode: attach user
+ child.user = request.user
+ child.verified = request.user.authenticated
+ child_event = child.new_event(request.user, "commented")
if request.namespace.hide_unless_approved:
# by default comments are approved, unless Namespace hide_unless_approved
# is enabled, moderators nodes are always auto approved.
- child.approved = request.namespace.is_moderator(request.user)
+ if request.user:
+ child.approved = request.namespace.is_moderator(request.user)
+ else:
+ # Anonymous users are never auto-approved when moderation is on
+ child.approved = False
# STEP 3: update root's changed timestamp.
# TODO: maybe we should find a better way to "bump" a thread.
@@ -90,29 +115,29 @@ def reply_node(request):
parent._invalidate_cache()
# STEP 4: commit to database.
- request.dbsession.add(request.user)
+ if request.user:
+ request.dbsession.add(request.user)
request.dbsession.add(child)
- request.dbsession.add(child_event)
+ if child_event:
+ request.dbsession.add(child_event)
request.dbsession.add(parent)
request.dbsession.add(parent.root)
request.dbsession.flush()
- schedule_notifications(request, child_event)
+ if child_event:
+ schedule_notifications(request, child_event)
msg = ("Your post was successful!", "success")
request.session.flash(msg)
- ### TODO: everything below this is pretty much crap code...
- # and likely deserves a flowchart...
-
# set return_to URI.
if request.mode == "embed":
return_to = get_embed_route_uri(request, child.root.uri.data, child.id)
else:
return_to = get_node_route_uri(request, child.root, child.id)
- if child.verified == True:
- # Redirect to new node if user and new node is verified.
+ # Anonymous users are always verified, redirect immediately
+ if user_surrogate or child.verified:
return HTTPFound(return_to)
set_node_to_pending_in_session(request, child)