diff --git a/chat.db b/chat.db index 23b1d02..74f9db3 100644 Binary files a/chat.db and b/chat.db differ diff --git a/controllers/__pycache__/chat.cpython-314.pyc b/controllers/__pycache__/chat.cpython-314.pyc index 2ff9416..f7965af 100644 Binary files a/controllers/__pycache__/chat.cpython-314.pyc and b/controllers/__pycache__/chat.cpython-314.pyc differ diff --git a/controllers/chat.py b/controllers/chat.py index 2b9329e..018877e 100644 --- a/controllers/chat.py +++ b/controllers/chat.py @@ -37,6 +37,23 @@ def init(db: DBfrontend, sec_cfg): _salt = sec_cfg.salt +def _get_client_ip() -> str: + forwarded = request.get_header("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + real_ip = request.get_header("X-Real-IP") + if real_ip: + return real_ip.strip() + client_id = request.get_header("X-Client-Id") + if client_id: + return "client-" + client_id + return request.environ.get("REMOTE_ADDR", "127.0.0.1") + + +def _hash_ip(ip: str) -> str: + return hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] + + def _check_rate(ip: str) -> bool: now = time.time() cutoff = now - _rate_window @@ -70,7 +87,7 @@ def register_routes(app): @app.route("/api/messages", method="POST") def post_message(): - ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") + ip = _get_client_ip() if not _check_rate(ip): response.status = 429 return {"success": False, "error": "Rate limit exceeded. Slow down."} @@ -97,7 +114,7 @@ def register_routes(app): content = _strip_xss(content) - ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] + ip_hash = _hash_ip(ip) msg_id = _db.add_message(channel, ip_hash, content, gif_url=gif_url) return {"success": True, "message_id": msg_id, "ip": ip_hash} @@ -134,7 +151,7 @@ def register_routes(app): @app.route("/api/polls", method="POST") def create_poll(): - ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") + ip = _get_client_ip() if not _check_rate(ip): response.status = 429 return {"success": False, "error": "Rate limit exceeded. Slow down."} @@ -172,7 +189,7 @@ def register_routes(app): response.status = 400 return {"success": False, "error": "Option too long"} - ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] + ip_hash = _hash_ip(ip) expires_at = data.get("expires_at") is_multiple = data.get("is_multiple", False) is_anonymous = data.get("is_anonymous", False) @@ -217,14 +234,14 @@ def register_routes(app): @app.route("/api/polls//vote", method="POST") def vote_poll(poll_id): - ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") + ip = _get_client_ip() try: data = request.json if not data or "option_id" not in data: response.status = 400 return {"success": False, "error": "option_id required"} - ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] + ip_hash = _hash_ip(ip) ok, msg = _db.vote(poll_id, data["option_id"], ip_hash) if not ok: response.status = 400 @@ -236,8 +253,8 @@ def register_routes(app): @app.route("/api/polls//close", method="POST") def close_poll(poll_id): - ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") - ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] + ip = _get_client_ip() + ip_hash = _hash_ip(ip) auth = request.get_header("Authorization", "") token = auth.replace("Bearer ", "") diff --git a/index.html b/index.html index 11a8663..5ee84bc 100644 --- a/index.html +++ b/index.html @@ -524,6 +524,21 @@ let selectedGifUrl = null; let votedPolls = new Set(); + function getClientId() { + let id = localStorage.getItem('clientId'); + if (!id) { + id = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2) + Date.now().toString(36); + localStorage.setItem('clientId', id); + } + return id; + } + + function apiFetch(url, options = {}) { + const headers = options.headers || {}; + headers['X-Client-Id'] = getClientId(); + return fetch(url, { ...options, headers }); + } + function loadVotedPolls() { try { const saved = localStorage.getItem('votedPolls_' + currentChannel); @@ -560,7 +575,7 @@ const url = query ? `/api/gifs/search?q=${encodeURIComponent(query)}&limit=20` : `/api/gifs/trending?limit=20`; - const r = await fetch(url); + const r = await apiFetch(url); const data = await r.json(); if (data.success) renderGifs(data.results || []); else gifGrid.innerHTML = '
Failed to load GIFs
'; @@ -695,7 +710,7 @@ pollCreate.disabled = true; try { - const r = await fetch('/api/polls', { + const r = await apiFetch('/api/polls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -748,7 +763,7 @@ async function loadMessages() { try { - const r = await fetch(`/api/messages?channel=${currentChannel}&limit=50`); + const r = await apiFetch(`/api/messages?channel=${currentChannel}&limit=50`); const data = await r.json(); if (data.success) { window._lastMessages = data.messages || []; @@ -830,7 +845,7 @@ if (votedPolls.has(pollId)) return; el.addEventListener('click', async () => { try { - const r = await fetch(`/api/polls/${pollId}/vote`, { + const r = await apiFetch(`/api/polls/${pollId}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ option_id: optId }) @@ -861,7 +876,7 @@ sendBtn.disabled = true; try { - const r = await fetch('/api/messages', { + const r = await apiFetch('/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: currentChannel, message: payload, gif_url: gif_url }) diff --git a/models/__pycache__/base.cpython-314.pyc b/models/__pycache__/base.cpython-314.pyc index 584c546..25c80b8 100644 Binary files a/models/__pycache__/base.cpython-314.pyc and b/models/__pycache__/base.cpython-314.pyc differ diff --git a/models/__pycache__/db.cpython-314.pyc b/models/__pycache__/db.cpython-314.pyc index b16e7d3..ab87587 100644 Binary files a/models/__pycache__/db.cpython-314.pyc and b/models/__pycache__/db.cpython-314.pyc differ diff --git a/models/base.py b/models/base.py index c5907b2..5735a02 100644 --- a/models/base.py +++ b/models/base.py @@ -61,8 +61,8 @@ class PollOption(Base): class PollVote(Base): __tablename__ = "poll_votes" __table_args__ = ( - UniqueConstraint("poll_id", "voter_ip", - name="uq_poll_voter"), + UniqueConstraint("poll_id", "option_id", "voter_ip", + name="uq_poll_voter_option"), ) id = Column(Integer, primary_key=True) diff --git a/models/db.py b/models/db.py index 0b9d355..6aec0a2 100644 --- a/models/db.py +++ b/models/db.py @@ -9,8 +9,9 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import base64 from sqlalchemy.orm import sessionmaker +from sqlalchemy.exc import IntegrityError -from .base import make_engine, Messages, Poll, PollOption, PollVote +from .base import make_engine, Base, Messages, Poll, PollOption, PollVote class DBfrontend: @@ -24,10 +25,23 @@ class DBfrontend: with self.Session() as session: from sqlalchemy import inspect, text inspector = inspect(session.connection()) + columns = [c["name"] for c in inspector.get_columns("messages")] if "poll_id" not in columns: session.execute(text("ALTER TABLE messages ADD COLUMN poll_id INTEGER")) - session.commit() + + constraints = [uq["name"] for uq in inspector.get_unique_constraints("poll_votes")] + if "uq_poll_voter" in constraints: + old = "poll_votes_old" + session.execute(text(f"ALTER TABLE poll_votes RENAME TO {old}")) + PollVote.__table__.create(session.connection()) + session.execute(text(f""" + INSERT INTO poll_votes (id, poll_id, option_id, voter_ip, voted_at) + SELECT id, poll_id, option_id, voter_ip, voted_at FROM {old} + """)) + session.execute(text(f"DROP TABLE {old}")) + + session.commit() @staticmethod def _get_cipher(secret: bytes, salt: bytes) -> Fernet: @@ -228,7 +242,11 @@ class DBfrontend: voted_at=int(time.time() * 1000), ) session.add(vote) - session.commit() + try: + session.commit() + except IntegrityError: + session.rollback() + return False, "You have already voted in this poll" return True, "Vote recorded" def close_poll(self, poll_id: int, requester_ip: str) -> tuple[bool, str]: