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:
parent
8d836dae34
commit
66964f736a
20 changed files with 1208 additions and 9 deletions
54
scripts/promote_superuser.py
Normal file
54
scripts/promote_superuser.py
Normal 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()
|
||||
80
scripts/spam/disable_spam.py
Normal file
80
scripts/spam/disable_spam.py
Normal 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
146
scripts/spam/scan.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue