FIX: ip detction and ip hashing
This commit is contained in:
parent
b5982063c4
commit
80da311288
Binary file not shown.
|
|
@ -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 ", "")
|
||||||
|
|
|
||||||
25
index.html
25
index.html
|
|
@ -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 })
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||||
|
|
|
||||||
24
models/db.py
24
models/db.py
|
|
@ -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]:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue