Bubble [main]

Rate limiting of registrations and posts by limited users

=> 7e0673d018696c70bc0c471e344cb7e0c879a673

diff --git a/50_bubble.py b/50_bubble.py
index 961f2e6..4eff66b 100644
--- a/50_bubble.py
+++ b/50_bubble.py
@@ -38,6 +38,8 @@ class Bubble:
         self.user_register = cfg.getboolean('user.register', True)
         self.user_subspaces = cfg.getboolean('user.subspaces', True)
         self.user_role_limited = cfg.getboolean('user.role.limited', True)
+        self.rate_register = max(1, cfg.getint('rate.register', 20)) # per hour, any remote
+        self.rate_post = max(1, cfg.getint('rate.post', 10)) # per hour, per remote
         self.admin_certpass = cfg.get('admin.certpass', '')
         self.antenna_url = cfg.get('antenna.url', 'gemini://warmedal.se/~antenna/submit')
         self.version = __version__
@@ -448,6 +450,8 @@ Bubble is open source:
                 if req.path.startswith(self.path + 'register/'):
                     if not session.bubble.user_register:
                         return 61, 'User registration is closed'
+                    if db.get_access_rate(3600, None, LogEntry.ACCOUNT_CREATED) >= session.bubble.rate_register:
+                        return 44, str(int(3600 / session.bubble.rate_register))
                     if not db.verify_token(session.user, req.path[len(self.path + 'register/'):]):
                         return 61, 'Expired'
                     try:
@@ -458,6 +462,7 @@ Bubble is open source:
                         db.create_user(username, req.identity,
                                        User.LIMITED if session.bubble.user_role_limited else \
                                        User.BASIC)
+                        db.add_log_entry(req.remote_address, LogEntry.ACCOUNT_CREATED)
 
                         page = f'# Welcome, {username}!\n\n'
                         if session.bubble.user_role_limited:
diff --git a/feeds.py b/feeds.py
index 06e92c3..983a164 100644
--- a/feeds.py
+++ b/feeds.py
@@ -1,6 +1,6 @@
 import re
 import urllib.parse as urlparse
-from model import User, Post, Segment, Subspace, Commit, Crossref, \
+from model import User, Post, Segment, Subspace, LogEntry, Commit, Crossref, \
     FOLLOW_SUBSPACE, FOLLOW_USER, FOLLOW_POST, \
     MUTE_SUBSPACE, MUTE_USER, MUTE_POST
 from subspace import subspace_admin_actions
@@ -99,11 +99,15 @@ def make_post_page_or_configure_feed(session):
         if session.is_context_locked:
             return 61, "Subspace is locked"
         if session.user.role == User.LIMITED and (not session.c_user or
-                                                  not session.c_user.id != session.user.id):
+                                                  session.c_user.id != session.user.id):
             return 61, "Not authorized"
-        if session.user.role == User.LIMITED and not db.verify_token(session.user, arg2):
+        if session.user.role == User.LIMITED and not db.verify_token(session.user, arg):
             return 61, "Expired"
+        if session.user.role == User.LIMITED and \
+                db.get_access_rate(3600, req.remote_address, LogEntry.POST_CREATED) >= session.bubble.rate_post:
+            return 44, str(int(3600 / session.bubble.rate_post))
         draft_id = db.create_post(session.user, session.context.id)
+        db.add_log_entry(req.remote_address, LogEntry.POST_CREATED)
         return 30, '/edit/%d' % draft_id
 
     elif action == 'post':
@@ -115,8 +119,11 @@ def make_post_page_or_configure_feed(session):
             return 61, "Subspace is locked"
         if session.user.role == User.LIMITED and not session.c_user:
             return 61, "Not authorized"
-        if session.user.role == User.LIMITED and not db.verify_token(session.user, arg2):
+        if session.user.role == User.LIMITED and not db.verify_token(session.user, arg):
             return 61, "Expired"
+        if session.user.role == User.LIMITED and \
+                db.get_access_rate(3600, req.remote_address, LogEntry.POST_CREATED) >= session.bubble.rate_post:
+            return 44, str(int(3600 / session.bubble.rate_post))
 
         if session.is_gemini:
             if is_empty_query(req):
@@ -181,6 +188,7 @@ def make_post_page_or_configure_feed(session):
             return 30, f'{session.server_root()}/edit/{post.id}'
 
         db.publish_post(post)
+        db.add_log_entry(req.remote_address, LogEntry.POST_CREATED)
         return 30, session.server_root() + post.page_url()
 
     if session.user:
@@ -629,12 +637,15 @@ def make_feed_page(session):
             page += f'=> /s/ {session.bubble.site_icon} Subspaces\n'
             page += session.FOOTER_MENU
         else:
-            token = session.get_token()
+            if session.user.role == User.LIMITED:
+                link_suffix = '/' + session.get_token()
+            else:
+                link_suffix = ''
             page += session.dashboard_link()
             if not session.is_context_locked:
                 if c_user and c_user.id == user.id:
-                    page += f'=> /u/{user.name}/post/{token} 💬 New post\n'
-                    page += f'=> /u/{user.name}/compose/{token} ✏️ Compose draft\n'
+                    page += f'=> /u/{user.name}/post{link_suffix} 💬 New post\n'
+                    page += f'=> /u/{user.name}/compose{link_suffix} ✏️ Compose draft\n'
                 elif context and context.owner == 0:
                     if is_issue_tracker:
                         if session.user.role != User.LIMITED:
@@ -642,10 +653,10 @@ def make_feed_page(session):
                     else:
                         if session.user.role != User.LIMITED:
                             page += f'=> /{context.title()}/post 💬 New post in s/{context.name}\n'
-                        page += f'=> /{context.title()}/compose/{token} ✏️ Compose draft in s/{context.name}\n'
+                        page += f'=> /{context.title()}/compose{link_suffix} ✏️ Compose draft in s/{context.name}\n'
                 else:
-                    page += f'=> /u/{user.name}/post/{token} 💬 New post in u/{user.name}\n'
-                    page += f'=> /u/{user.name}/compose/{token} ✏️ Compose draft in u/{user.name}\n'
+                    page += f'=> /u/{user.name}/post{link_suffix} 💬 New post in u/{user.name}\n'
+                    page += f'=> /u/{user.name}/compose{link_suffix} ✏️ Compose draft in u/{user.name}\n'
             page += f'=> /s/ {session.bubble.site_icon} Subspaces\n'
 
             if is_issue_tracker:
diff --git a/model.py b/model.py
index e1e9a40..ca39e44 100644
--- a/model.py
+++ b/model.py
@@ -1,4 +1,5 @@
 import datetime
+import hashlib
 import mariadb
 import os
 import random
@@ -25,11 +26,21 @@ def parse_asn1_time(asn1_bytes):
     return None
 
 
+def address_hash(from_addr):
+    m = hashlib.sha256()
+    m.update(from_addr[0].encode('utf-8'))
+    return m.hexdigest()
+
+
 # NOTE: All enum values are used in database, don't change them!
 FOLLOW_USER, FOLLOW_POST, FOLLOW_SUBSPACE = range(3)
 MUTE_USER, MUTE_POST, MUTE_SUBSPACE = range(3)
 
 
+class LogEntry:
+    ACCOUNT_CREATED, POST_CREATED = range(2)
+
+
 class Segment:
     TEXT, LINK, IMAGE, ATTACHMENT, POLL = range(5)
 
@@ -637,6 +648,14 @@ class Database:
             INDEX (dst_issueid)
         )""")
 
+        db.execute("""CREATE TABLE IF NOT EXISTS log (
+            remote      CHAR(64) NOT NULL,
+            type        INT NOT NULL,
+            ts          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            INDEX (remote),
+            INDEX (type)
+        )""")
+
         db.execute('INSERT IGNORE INTO users (name, avatar, role, password, ts_password) '
                    'VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP())',
                    ('admin', '🚀', User.ADMIN, admin_certpass))
@@ -2402,6 +2421,30 @@ class Database:
             commits.append(Commit(repo.id, hash, msg, ts))
         return commits
 
+    def get_access_rate(self, window_seconds, from_addr, entry_type):
+        cond = ['type=?']
+        values = [entry_type]
+        if from_addr != None:
+            cond.append('remote=?')
+            values.append(address_hash(from_addr))
+        cond.append('TIMESTAMPDIFF(SECOND, ts, CURRENT_TIMESTAMP())=60")
+        self.commit()
+
+        return rate
+
+    def add_log_entry(self, from_addr, type):
+        cur = self.conn.cursor()
+        cur.execute("INSERT INTO log (remote, type) VALUES (?, ?)", (address_hash(from_addr), type))
+        self.commit()
+
 
 class Search:
     def __init__(self, db):
Proxy Information
Original URL
gemini://git.skyjake.fi/bubble/main/cdiff/7e0673d018696c70bc0c471e344cb7e0c879a673
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
29.764177 milliseconds
Gemini-to-HTML Time
0.537954 milliseconds

This content has been proxied by September (3851b).