changed vote limit to cookie-based rather than IP-based

This commit is contained in:
Milan 2026-06-20 15:25:13 +02:00
parent a484f13e7d
commit 8dbddf8fb8
3 changed files with 136 additions and 22 deletions

View File

@ -1,5 +1,6 @@
from functools import wraps
import sqlite3
from datetime import date
from flask import (
Blueprint,
@ -84,6 +85,13 @@ def validate_account_form(form, admin):
return data, None
def question_content_changed(question, data):
return any(
question[field] != data[field]
for field in ("question", "option_a", "option_b", "date")
)
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
@ -246,6 +254,11 @@ def edit_question(question_id):
if error is None:
try:
should_clear_votes = (
question["date"] == date.today().isoformat()
and question_content_changed(question, data)
)
db.execute(
"""
UPDATE questions
@ -260,6 +273,10 @@ def edit_question(question_id):
question_id,
),
)
if should_clear_votes:
db.execute("DELETE FROM votes WHERE question_id = ?", (question_id,))
db.commit()
return redirect(url_for("admin.dashboard"))
except sqlite3.IntegrityError:

View File

@ -1,5 +1,5 @@
import hashlib
from datetime import date
from datetime import date, datetime, time, timedelta
from flask import Blueprint, redirect, render_template, request, url_for
@ -7,6 +7,8 @@ from .database import get_db
bp = Blueprint("public", __name__)
VOTE_COOKIE_NAME = "wyr_daily_vote"
def get_todays_question():
db = get_db()
@ -23,6 +25,46 @@ def get_todays_question():
).fetchone()
def get_question_version(question):
parts = (
str(question["id"]),
question["date"],
question["question"],
question["option_a"],
question["option_b"],
)
fingerprint = "\x1f".join(parts)
return hashlib.sha256(fingerprint.encode()).hexdigest()
def get_vote_cookie_value(question):
return f"{question['date']}:{question['id']}:{get_question_version(question)}"
def get_seconds_until_tomorrow():
now = datetime.now()
tomorrow = datetime.combine(date.today() + timedelta(days=1), time.min)
return max(1, int((tomorrow - now).total_seconds()))
def has_voted_for_question(question):
return request.cookies.get(VOTE_COOKIE_NAME) == get_vote_cookie_value(question)
def set_vote_cookie(response, question):
response.set_cookie(
VOTE_COOKIE_NAME,
get_vote_cookie_value(question),
max_age=get_seconds_until_tomorrow(),
httponly=True,
samesite="Lax",
)
return response
@bp.route("/")
def home():
question = get_todays_question()
@ -41,36 +83,26 @@ def vote():
if not qid or selected_vote not in {"A", "B"}:
return redirect(url_for("public.home"))
ip = request.remote_addr or ""
voter_hash = hashlib.sha256(ip.encode()).hexdigest()
db = get_db()
question = get_todays_question()
if question is None or str(question["id"]) != str(qid):
return redirect(url_for("public.home"))
existing = db.execute(
if has_voted_for_question(question):
return redirect(url_for("public.results"))
db.execute(
"""
SELECT id
FROM votes
WHERE voter_hash = ?
AND question_id = ?
INSERT INTO votes (question_id, vote, voter_hash)
VALUES (?, ?, ?)
""",
(voter_hash, qid),
).fetchone()
(qid, selected_vote, "cookie"),
)
db.commit()
if existing is None:
db.execute(
"""
INSERT INTO votes (question_id, vote, voter_hash)
VALUES (?, ?, ?)
""",
(qid, selected_vote, voter_hash),
)
db.commit()
return redirect(url_for("public.results"))
response = redirect(url_for("public.results"))
return set_vote_cookie(response, question)
@bp.route("/results")

View File

@ -80,6 +80,35 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(count, 1)
def test_new_browser_can_vote_without_cookie(self):
self.seed_question()
first = self.client.post("/vote", data={"qid": "1", "vote": "A"})
other_client = self.app.test_client()
second = other_client.post("/vote", data={"qid": "1", "vote": "B"})
self.assertEqual(first.status_code, 302)
self.assertEqual(second.status_code, 302)
with self.app.app_context():
count = get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
self.assertEqual(count, 2)
def test_invalid_vote_does_not_set_cookie(self):
response = self.client.post("/vote", data={"qid": "", "vote": "A"})
self.assertEqual(response.status_code, 302)
self.assertNotIn("wyr_daily_vote", response.headers.get("Set-Cookie", ""))
def test_non_today_vote_does_not_set_cookie(self):
self.seed_question("2030-01-01")
response = self.client.post("/vote", data={"qid": "1", "vote": "A"})
self.assertEqual(response.status_code, 302)
self.assertNotIn("wyr_daily_vote", response.headers.get("Set-Cookie", ""))
def test_homepage_uses_todays_question_only(self):
self.seed_question("2030-01-01")
response = self.client.get("/")
@ -103,6 +132,42 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(count, 0)
def test_editing_todays_question_clears_votes_and_allows_new_vote(self):
self.seed_question()
self.client.post("/vote", data={"qid": "1", "vote": "A"})
self.login()
response = self.client.post(
"/admin/question/edit/1",
data={
"date": date.today().isoformat(),
"question": "Would you rather retest A or retest B?",
"option_a": "Retest A",
"option_b": "Retest B",
},
)
self.assertEqual(response.status_code, 302)
with self.app.app_context():
count_after_edit = (
get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
)
self.assertEqual(count_after_edit, 0)
self.client.post("/vote", data={"qid": "1", "vote": "B"})
with self.app.app_context():
db = get_db()
total = db.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
vote_b = db.execute(
"SELECT COUNT(*) FROM votes WHERE vote = 'B'",
).fetchone()[0]
self.assertEqual(total, 1)
self.assertEqual(vote_b, 1)
def test_admin_login_success_and_failure(self):
failed = self.client.post(
"/admin/login",