FIX: ip detction and ip hashing

This commit is contained in:
Your Name 2026-07-22 16:13:41 +03:00
parent b5982063c4
commit 80da311288
8 changed files with 68 additions and 18 deletions

BIN
chat.db

Binary file not shown.

View File

@ -37,6 +37,23 @@ def init(db: DBfrontend, sec_cfg):
_salt = sec_cfg.salt _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: def _check_rate(ip: str) -> bool:
now = time.time() now = time.time()
cutoff = now - _rate_window cutoff = now - _rate_window
@ -70,7 +87,7 @@ def register_routes(app):
@app.route("/api/messages", method="POST") @app.route("/api/messages", method="POST")
def post_message(): def post_message():
ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") ip = _get_client_ip()
if not _check_rate(ip): if not _check_rate(ip):
response.status = 429 response.status = 429
return {"success": False, "error": "Rate limit exceeded. Slow down."} return {"success": False, "error": "Rate limit exceeded. Slow down."}
@ -97,7 +114,7 @@ def register_routes(app):
content = _strip_xss(content) 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) msg_id = _db.add_message(channel, ip_hash, content, gif_url=gif_url)
return {"success": True, "message_id": msg_id, "ip": ip_hash} return {"success": True, "message_id": msg_id, "ip": ip_hash}
@ -134,7 +151,7 @@ def register_routes(app):
@app.route("/api/polls", method="POST") @app.route("/api/polls", method="POST")
def create_poll(): def create_poll():
ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") ip = _get_client_ip()
if not _check_rate(ip): if not _check_rate(ip):
response.status = 429 response.status = 429
return {"success": False, "error": "Rate limit exceeded. Slow down."} return {"success": False, "error": "Rate limit exceeded. Slow down."}
@ -172,7 +189,7 @@ def register_routes(app):
response.status = 400 response.status = 400
return {"success": False, "error": "Option too long"} 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") expires_at = data.get("expires_at")
is_multiple = data.get("is_multiple", False) is_multiple = data.get("is_multiple", False)
is_anonymous = data.get("is_anonymous", False) is_anonymous = data.get("is_anonymous", False)
@ -217,14 +234,14 @@ def register_routes(app):
@app.route("/api/polls/<poll_id:int>/vote", method="POST") @app.route("/api/polls/<poll_id:int>/vote", method="POST")
def vote_poll(poll_id): def vote_poll(poll_id):
ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") ip = _get_client_ip()
try: try:
data = request.json data = request.json
if not data or "option_id" not in data: if not data or "option_id" not in data:
response.status = 400 response.status = 400
return {"success": False, "error": "option_id required"} 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) ok, msg = _db.vote(poll_id, data["option_id"], ip_hash)
if not ok: if not ok:
response.status = 400 response.status = 400
@ -236,8 +253,8 @@ def register_routes(app):
@app.route("/api/polls/<poll_id:int>/close", method="POST") @app.route("/api/polls/<poll_id:int>/close", method="POST")
def close_poll(poll_id): def close_poll(poll_id):
ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") ip = _get_client_ip()
ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] ip_hash = _hash_ip(ip)
auth = request.get_header("Authorization", "") auth = request.get_header("Authorization", "")
token = auth.replace("Bearer ", "") token = auth.replace("Bearer ", "")

View File

@ -524,6 +524,21 @@
let selectedGifUrl = null; let selectedGifUrl = null;
let votedPolls = new Set(); 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() { function loadVotedPolls() {
try { try {
const saved = localStorage.getItem('votedPolls_' + currentChannel); const saved = localStorage.getItem('votedPolls_' + currentChannel);
@ -560,7 +575,7 @@
const url = query const url = query
? `/api/gifs/search?q=${encodeURIComponent(query)}&limit=20` ? `/api/gifs/search?q=${encodeURIComponent(query)}&limit=20`
: `/api/gifs/trending?limit=20`; : `/api/gifs/trending?limit=20`;
const r = await fetch(url); const r = await apiFetch(url);
const data = await r.json(); const data = await r.json();
if (data.success) renderGifs(data.results || []); if (data.success) renderGifs(data.results || []);
else gifGrid.innerHTML = '<div class="empty-state">Failed to load GIFs</div>'; else gifGrid.innerHTML = '<div class="empty-state">Failed to load GIFs</div>';
@ -695,7 +710,7 @@
pollCreate.disabled = true; pollCreate.disabled = true;
try { try {
const r = await fetch('/api/polls', { const r = await apiFetch('/api/polls', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@ -748,7 +763,7 @@
async function loadMessages() { async function loadMessages() {
try { 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(); const data = await r.json();
if (data.success) { if (data.success) {
window._lastMessages = data.messages || []; window._lastMessages = data.messages || [];
@ -830,7 +845,7 @@
if (votedPolls.has(pollId)) return; if (votedPolls.has(pollId)) return;
el.addEventListener('click', async () => { el.addEventListener('click', async () => {
try { try {
const r = await fetch(`/api/polls/${pollId}/vote`, { const r = await apiFetch(`/api/polls/${pollId}/vote`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ option_id: optId }) body: JSON.stringify({ option_id: optId })
@ -861,7 +876,7 @@
sendBtn.disabled = true; sendBtn.disabled = true;
try { try {
const r = await fetch('/api/messages', { const r = await apiFetch('/api/messages', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel: currentChannel, message: payload, gif_url: gif_url }) body: JSON.stringify({ channel: currentChannel, message: payload, gif_url: gif_url })

View File

@ -61,8 +61,8 @@ class PollOption(Base):
class PollVote(Base): class PollVote(Base):
__tablename__ = "poll_votes" __tablename__ = "poll_votes"
__table_args__ = ( __table_args__ = (
UniqueConstraint("poll_id", "voter_ip", UniqueConstraint("poll_id", "option_id", "voter_ip",
name="uq_poll_voter"), name="uq_poll_voter_option"),
) )
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)

View File

@ -9,8 +9,9 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64 import base64
from sqlalchemy.orm import sessionmaker 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: class DBfrontend:
@ -24,10 +25,23 @@ class DBfrontend:
with self.Session() as session: with self.Session() as session:
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
inspector = inspect(session.connection()) inspector = inspect(session.connection())
columns = [c["name"] for c in inspector.get_columns("messages")] columns = [c["name"] for c in inspector.get_columns("messages")]
if "poll_id" not in columns: if "poll_id" not in columns:
session.execute(text("ALTER TABLE messages ADD COLUMN poll_id INTEGER")) 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 @staticmethod
def _get_cipher(secret: bytes, salt: bytes) -> Fernet: def _get_cipher(secret: bytes, salt: bytes) -> Fernet:
@ -228,7 +242,11 @@ class DBfrontend:
voted_at=int(time.time() * 1000), voted_at=int(time.time() * 1000),
) )
session.add(vote) 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" return True, "Vote recorded"
def close_poll(self, poll_id: int, requester_ip: str) -> tuple[bool, str]: def close_poll(self, poll_id: int, requester_ip: str) -> tuple[bool, str]: