Add spam prevention, superuser system, and LLM relevance checking

- Thread creation rate limit: 1 per 7 min per user/IP via API
- is_superuser column on User model with alembic migration
- Superusers bypass namespace-scoped is_moderator() checks
- super_fly_required now checks is_superuser instead of hardcoded names
- /topsecret/users admin page to promote/demote superusers
- Spam scoring module with 6 signals (link density, patterns, duplicates,
  new account velocity, IP reputation, content length)
- Hard threshold (0.8) rejects, soft threshold (0.5) holds for moderation
- LLM relevance checking via Hermes (hermes.ai.unturf.com) on every
  message when enabled, including embed mode parent page URL context
- Admin API endpoints: GET /api/v1/admin/namespaces, recent-nodes
- Spam hunting scripts: scan.py, disable_spam.py, promote_superuser.py
- Updated Python client with admin methods
This commit is contained in:
russell@unturf.com 2026-02-02 09:19:16 -05:00
parent 8d836dae34
commit 66964f736a
20 changed files with 1208 additions and 9 deletions

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Promote or demote a user to/from superuser status.
This is a direct database script for bootstrapping the first superuser.
After that, use the /topsecret/users web UI to manage superusers.
Usage:
python scripts/promote_superuser.py --ini development.ini --email user@example.com
python scripts/promote_superuser.py --ini development.ini --email user@example.com --demote
"""
import argparse
import sys
from pyramid.paster import bootstrap
def main():
parser = argparse.ArgumentParser(description="Promote/demote superuser")
parser.add_argument("--ini", required=True, help="Path to .ini config file")
parser.add_argument("--email", required=True, help="User email address")
parser.add_argument("--demote", action="store_true", help="Demote instead of promote")
args = parser.parse_args()
env = bootstrap(args.ini)
request = env["request"]
dbsession = request.dbsession
from remarkbox.models.user import get_user_by_email
user = get_user_by_email(dbsession, args.email)
if user is None:
print("No user found with email: {}".format(args.email), file=sys.stderr)
sys.exit(1)
if args.demote:
user.is_superuser = False
dbsession.add(user)
import transaction
transaction.commit()
print("Demoted {} ({}) from superuser.".format(user.name, user.email))
else:
user.is_superuser = True
dbsession.add(user)
import transaction
transaction.commit()
print("Promoted {} ({}) to superuser.".format(user.name, user.email))
env["closer"]()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Bulk disable spam nodes on Remarkbox.
Requires a superuser session cookie.
Usage:
python scripts/spam/disable_spam.py node_id [node_id ...]
python scripts/spam/scan.py --json | python scripts/spam/disable_spam.py --from-json
Examples:
python scripts/spam/disable_spam.py abc123 def456
python scripts/spam/scan.py --json --threshold=0.8 | python scripts/spam/disable_spam.py --from-json
"""
import argparse
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "remarkbox", "api"))
from remarkbox_client import RemarkboxClient
def main():
parser = argparse.ArgumentParser(description="Bulk disable spam nodes")
parser.add_argument("node_ids", nargs="*", help="Node IDs to disable")
parser.add_argument("--from-json", action="store_true",
help="Read node IDs from JSON on stdin (from scan.py --json)")
parser.add_argument("--url", default="https://my.remarkbox.com", help="Remarkbox URL")
parser.add_argument("--cookie", default=os.path.expanduser("~/.config/remarkbox/cookies.txt"),
help="Cookie file path")
parser.add_argument("--dry-run", action="store_true", help="Just print what would be disabled")
args = parser.parse_args()
node_ids = list(args.node_ids)
if args.from_json:
data = json.load(sys.stdin)
if isinstance(data, list):
node_ids.extend(item["id"] for item in data if "id" in item)
elif isinstance(data, dict) and "nodes" in data:
node_ids.extend(item["id"] for item in data["nodes"] if "id" in item)
if not node_ids:
print("No node IDs provided. Use positional args or --from-json.", file=sys.stderr)
sys.exit(1)
# Deduplicate
node_ids = list(dict.fromkeys(node_ids))
client = RemarkboxClient(args.url, cookie_file=args.cookie)
disabled = 0
failed = 0
skipped = 0
for node_id in node_ids:
if args.dry_run:
print("[dry-run] would disable {}".format(node_id))
continue
try:
client.disable_node(node_id)
print("[ok] disabled {}".format(node_id))
disabled += 1
time.sleep(0.5)
except Exception as e:
print("[FAIL] {}: {}".format(node_id, e), file=sys.stderr)
failed += 1
if args.dry_run:
print("\nDry run: {} nodes would be disabled.".format(len(node_ids)))
else:
print("\nDone. {} disabled, {} failed.".format(disabled, failed))
if __name__ == "__main__":
main()

146
scripts/spam/scan.py Normal file
View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Scan recent Remarkbox posts for spam.
Requires a superuser session cookie.
Usage:
python scripts/spam/scan.py [--days=7] [--threshold=0.3] [--namespace=X] [--limit=100]
Examples:
python scripts/spam/scan.py
python scripts/spam/scan.py --days=30 --threshold=0.1
python scripts/spam/scan.py --namespace=meta.remarkbox.com
"""
import argparse
import json
import os
import re
import sys
# Add the remarkbox api dir to path for the client
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "remarkbox", "api"))
from remarkbox_client import RemarkboxClient
# Client-side spam scoring (mirrors server-side logic in remarkbox/models/spam.py)
DEFAULT_PATTERNS = [
r"buy\s+now", r"click\s+here\s+to", r"free\s+trial",
r"limited\s+time\s+offer", r"act\s+now", r"order\s+today",
r"100%\s+free", r"make\s+money\s+fast", r"work\s+from\s+home",
r"casino\s+online", r"viagra|cialis", r"payday\s+loan",
r"seo\s+service", r"followers?\s+for\s+(free|sale|\$)",
r"crypto\s+invest",
]
COMPILED_PATTERNS = [re.compile(p, re.IGNORECASE) for p in DEFAULT_PATTERNS]
def score_content_client(text):
"""Score content for spam (client-side, no DB access)."""
if not text:
return 0.0, []
signals = []
score = 0.0
# Link density
urls = re.findall(r"https?://\S+", text)
url_chars = sum(len(u) for u in urls)
density = url_chars / len(text) if text else 0.0
if density > 0.5:
score += 0.4
signals.append("link_density:{:.0%}".format(density))
elif density > 0.3:
score += 0.2
signals.append("link_density:{:.0%}".format(density))
if len(urls) > 5:
score += 0.2
signals.append("link_count:{}".format(len(urls)))
# Spam patterns
hits = sum(1 for p in COMPILED_PATTERNS if p.search(text))
if hits >= 3:
score += 0.5
signals.append("spam_patterns:{}".format(hits))
elif hits >= 1:
score += 0.2
signals.append("spam_patterns:{}".format(hits))
# Very short
if len(text) < 10:
score += 0.1
signals.append("very_short")
return min(score, 1.0), signals
def main():
parser = argparse.ArgumentParser(description="Scan Remarkbox for spam")
parser.add_argument("--days", type=int, default=7, help="Days to look back (default 7)")
parser.add_argument("--threshold", type=float, default=0.3, help="Minimum score to report (default 0.3)")
parser.add_argument("--namespace", help="Limit to a specific namespace")
parser.add_argument("--limit", type=int, default=100, help="Max nodes to scan (default 100)")
parser.add_argument("--url", default="https://my.remarkbox.com", help="Remarkbox URL")
parser.add_argument("--cookie", default=os.path.expanduser("~/.config/remarkbox/cookies.txt"),
help="Cookie file path")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
client = RemarkboxClient(args.url, cookie_file=args.cookie)
# Fetch recent nodes via admin endpoint
try:
result = client._request("GET", "/api/v1/admin/recent-nodes?days={}&limit={}".format(
args.days, args.limit
))
except Exception as e:
print("Error: {}".format(e), file=sys.stderr)
print("Make sure you are authenticated as a superuser.", file=sys.stderr)
sys.exit(1)
flagged = []
for node in result.get("nodes", []):
# Filter by namespace if specified
if args.namespace and node.get("namespace") != args.namespace:
continue
text = node.get("data", "") or ""
spam_score, signals = score_content_client(text)
if spam_score >= args.threshold:
flagged.append({
"id": node["id"],
"namespace": node.get("namespace"),
"author": node.get("author"),
"ip": node.get("ip_address"),
"score": round(spam_score, 2),
"signals": signals,
"disabled": node.get("disabled"),
"preview": text[:100].replace("\n", " "),
"created_ago": node.get("created_ago"),
})
if args.json:
print(json.dumps(flagged, indent=2))
else:
if not flagged:
print("No spam found above threshold {}.".format(args.threshold))
return
print("Found {} suspicious posts (threshold {}):".format(len(flagged), args.threshold))
print()
for item in flagged:
status = "DISABLED" if item["disabled"] else "active"
print(" [{:.1f}] {} [{}]".format(item["score"], item["id"], status))
print(" namespace: {} author: {} ip: {}".format(
item["namespace"], item["author"], item["ip"]
))
print(" signals: {}".format(", ".join(item["signals"])))
print(" preview: {}".format(item["preview"]))
print()
if __name__ == "__main__":
main()