diff --git a/chat.db b/chat.db index aa8949b..bf0161f 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 e026b82..2ff9416 100644 Binary files a/controllers/__pycache__/chat.cpython-314.pyc and b/controllers/__pycache__/chat.cpython-314.pyc differ diff --git a/controllers/__pycache__/tenor.cpython-314.pyc b/controllers/__pycache__/tenor.cpython-314.pyc index 25527c1..5f49f25 100644 Binary files a/controllers/__pycache__/tenor.cpython-314.pyc and b/controllers/__pycache__/tenor.cpython-314.pyc differ diff --git a/controllers/chat.py b/controllers/chat.py index 4a35de9..2b9329e 100644 --- a/controllers/chat.py +++ b/controllers/chat.py @@ -131,3 +131,125 @@ def register_routes(app): except Exception as e: response.status = 500 return {"success": False, "error": str(e)} + + @app.route("/api/polls", method="POST") + def create_poll(): + ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") + if not _check_rate(ip): + response.status = 429 + return {"success": False, "error": "Rate limit exceeded. Slow down."} + + try: + data = request.json + if not data: + response.status = 400 + return {"success": False, "error": "No data"} + + channel = data.get("channel", "general") + question = data.get("question", "").strip() + options = data.get("options", []) + + if channel not in VALID_CHANNELS: + response.status = 400 + return {"success": False, "error": "Invalid channel"} + if not question: + response.status = 400 + return {"success": False, "error": "Question is required"} + if len(question) > 500: + response.status = 400 + return {"success": False, "error": "Question too long"} + if len(options) < 2: + response.status = 400 + return {"success": False, "error": "At least 2 options required"} + if len(options) > 20: + response.status = 400 + return {"success": False, "error": "Maximum 20 options allowed"} + for opt in options: + if not opt.strip(): + response.status = 400 + return {"success": False, "error": "Option cannot be empty"} + if len(opt) > 200: + response.status = 400 + return {"success": False, "error": "Option too long"} + + ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16] + expires_at = data.get("expires_at") + is_multiple = data.get("is_multiple", False) + is_anonymous = data.get("is_anonymous", False) + + poll_id = _db.add_poll( + channel=channel, + creator_ip=ip_hash, + question=question, + options=options, + expires_at=expires_at, + is_multiple=is_multiple, + is_anonymous=is_anonymous, + ) + return {"success": True, "poll_id": poll_id} + except Exception as e: + response.status = 500 + return {"success": False, "error": str(e)} + + @app.route("/api/polls", method="GET") + def list_polls(): + try: + channel = request.query.get("channel", "general") + if channel not in VALID_CHANNELS: + channel = "general" + polls = _db.get_polls(channel=channel) + return {"success": True, "polls": polls} + except Exception as e: + response.status = 500 + return {"success": False, "error": str(e)} + + @app.route("/api/polls/", method="GET") + def get_poll(poll_id): + try: + poll = _db.get_poll(poll_id) + if not poll: + response.status = 404 + return {"success": False, "error": "Poll not found"} + return {"success": True, "poll": poll} + except Exception as e: + response.status = 500 + return {"success": False, "error": str(e)} + + @app.route("/api/polls//vote", method="POST") + def vote_poll(poll_id): + ip = request.environ.get("REMOTE_ADDR", "127.0.0.1") + 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] + ok, msg = _db.vote(poll_id, data["option_id"], ip_hash) + if not ok: + response.status = 400 + return {"success": False, "error": msg} + return {"success": True, "message": msg} + except Exception as e: + response.status = 500 + return {"success": False, "error": str(e)} + + @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] + + auth = request.get_header("Authorization", "") + token = auth.replace("Bearer ", "") + if _admin_token and token == _admin_token: + ok, msg = _db.close_poll(poll_id, requester_ip=ip_hash) + if not ok: + response.status = 400 + return {"success": False, "error": msg} + return {"success": True, "message": msg} + + ok, msg = _db.close_poll(poll_id, requester_ip=ip_hash) + if not ok: + response.status = 400 + return {"success": False, "error": msg} + return {"success": True, "message": msg} diff --git a/index.html b/index.html index 586d38f..a1850b9 100644 --- a/index.html +++ b/index.html @@ -245,6 +245,220 @@ .toast.error { background: #c0392b; color: #fff; } .toast.success { background: #27ae60; color: #fff; } .toast.info { background: #2980b9; color: #fff; } + .poll-card { + background: #1a2d4a; + border-radius: 8px; + padding: 14px; + margin-bottom: 12px; + border-left: 3px solid #f39c12; + } + .poll-card .poll-question { + font-size: 15px; + font-weight: 600; + color: #f1c40f; + margin-bottom: 10px; + } + .poll-card .poll-meta { + font-size: 11px; + color: #6a8aaa; + margin-bottom: 8px; + } + .poll-card .poll-option { + display: flex; + align-items: center; + padding: 8px 10px; + margin-bottom: 4px; + background: #0f2040; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; + position: relative; + overflow: hidden; + } + .poll-card .poll-option:hover { background: #1a3a5a; } + .poll-card .poll-option .poll-bar { + position: absolute; + left: 0; top: 0; bottom: 0; + background: rgba(46, 204, 113, 0.15); + border-radius: 6px; + transition: width 0.3s; + } + .poll-card .poll-option .poll-label { + position: relative; + z-index: 1; + flex: 1; + font-size: 14px; + } + .poll-card .poll-option .poll-pct { + position: relative; + z-index: 1; + font-size: 12px; + color: #7ec8a3; + min-width: 36px; + text-align: right; + } + .poll-card .poll-option .poll-votes { + position: relative; + z-index: 1; + font-size: 11px; + color: #556677; + margin-left: 8px; + min-width: 20px; + text-align: right; + } + .poll-card .poll-option.voted { + border: 1px solid #2ecc71; + } + .poll-card .poll-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + font-size: 12px; + color: #556677; + } + .poll-card .poll-footer button { + padding: 4px 12px; + background: transparent; + border: 1px solid #6a8aaa; + border-radius: 4px; + color: #a0c4ff; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + } + .poll-card .poll-footer button:hover { + background: #e94560; + border-color: #e94560; + color: #fff; + } + .poll-card .poll-status { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + } + .poll-card .poll-status.active { background: #27ae60; color: #fff; } + .poll-card .poll-status.closed { background: #7f8c8d; color: #fff; } + .poll-modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 2000; + justify-content: center; + align-items: center; + } + .poll-modal-overlay.open { display: flex; } + .poll-modal { + background: #16213e; + border-radius: 12px; + padding: 24px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + } + .poll-modal h2 { + color: #f1c40f; + margin-bottom: 16px; + font-size: 18px; + } + .poll-modal label { + display: block; + font-size: 13px; + color: #a0c4ff; + margin-bottom: 4px; + margin-top: 12px; + } + .poll-modal input[type="text"], + .poll-modal textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #1a4a7a; + border-radius: 6px; + background: #121e38; + color: #e0e0e0; + font-size: 14px; + outline: none; + } + .poll-modal input:focus, .poll-modal textarea:focus { border-color: #e94560; } + .poll-modal textarea { resize: vertical; min-height: 60px; font-family: inherit; } + .poll-modal .poll-opt-row { + display: flex; + gap: 8px; + margin-bottom: 6px; + align-items: center; + } + .poll-modal .poll-opt-row input { flex: 1; } + .poll-modal .poll-opt-row button { + padding: 6px 10px; + background: transparent; + border: 1px solid #e74c3c; + border-radius: 4px; + color: #e74c3c; + cursor: pointer; + font-size: 14px; + } + .poll-modal .poll-opt-row button:hover { background: #e74c3c; color: #fff; } + .poll-modal .add-opt-btn { + padding: 6px 14px; + background: transparent; + border: 1px dashed #2a4a7a; + border-radius: 6px; + color: #6a8aaa; + cursor: pointer; + font-size: 13px; + margin-top: 4px; + width: 100%; + } + .poll-modal .add-opt-btn:hover { + border-color: #e94560; + color: #e94560; + } + .poll-modal .modal-actions { + display: flex; + gap: 10px; + margin-top: 16px; + justify-content: flex-end; + } + .poll-modal .modal-actions button { + padding: 8px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + font-weight: 600; + } + .poll-modal .modal-actions .btn-cancel { + background: #2a3a5a; + color: #8899aa; + } + .poll-modal .modal-actions .btn-cancel:hover { background: #3a4a6a; } + .poll-modal .modal-actions .btn-create { + background: #e94560; + color: #fff; + } + .poll-modal .modal-actions .btn-create:hover { background: #d63850; } + .poll-modal .modal-actions .btn-create:disabled { + background: #4a5a7a; + cursor: not-allowed; + } + .poll-modal .poll-check-row { + display: flex; + gap: 16px; + margin-top: 12px; + } + .poll-modal .poll-check-row label { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + cursor: pointer; + font-size: 13px; + } + .poll-modal .poll-check-row input[type="checkbox"] { + accent-color: #e94560; + } @@ -264,11 +478,39 @@
+
+
+
+

๐Ÿ“Š Create Poll

+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
diff --git a/models/__pycache__/base.cpython-314.pyc b/models/__pycache__/base.cpython-314.pyc index 8afc256..7d3d6db 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 956a500..dc611d9 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 28de686..f4f9248 100644 --- a/models/base.py +++ b/models/base.py @@ -3,9 +3,12 @@ models/base.py โ€” declarative base & the Messages model. """ from sqlalchemy import create_engine, Integer, String, Text -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker from typing import Optional - +from sqlalchemy import ( + Column, Integer, String, Text, Boolean, BigInteger, + ForeignKey, UniqueConstraint +) class Base(DeclarativeBase): pass @@ -21,6 +24,54 @@ class Messages(Base): gif_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None) timestamp: Mapped[int] = mapped_column(Integer, nullable=False) +class Poll(Base): + __tablename__ = "polls" + + id = Column(Integer, primary_key=True) + channel = Column(String(32), nullable=False, default="general") + creator_ip = Column(String(64), nullable=False) + question = Column(Text, nullable=False) # encrypted + created_at = Column(BigInteger, nullable=False) + expires_at = Column(BigInteger, nullable=True) + is_multiple = Column(Boolean, default=False) + is_anonymous = Column(Boolean, default=False) + status = Column(String(16), default="active") # active | closed | deleted + + options = relationship("PollOption", back_populates="poll", + cascade="all, delete-orphan", + order_by="PollOption.position") + votes = relationship("PollVote", back_populates="poll", + cascade="all, delete-orphan") + + +class PollOption(Base): + __tablename__ = "poll_options" + + id = Column(Integer, primary_key=True) + poll_id = Column(Integer, ForeignKey("polls.id"), nullable=False) + label = Column(Text, nullable=False) # encrypted + position = Column(Integer, default=0) + + poll = relationship("Poll", back_populates="options") + votes = relationship("PollVote", back_populates="option", + cascade="all, delete-orphan") + + +class PollVote(Base): + __tablename__ = "poll_votes" + __table_args__ = ( + UniqueConstraint("poll_id", "voter_ip", + name="uq_poll_voter"), + ) + + id = Column(Integer, primary_key=True) + poll_id = Column(Integer, ForeignKey("polls.id"), nullable=False) + option_id = Column(Integer, ForeignKey("poll_options.id"), nullable=False) + voter_ip = Column(String(64), nullable=False) # IP-hash + voted_at = Column(BigInteger, nullable=False) + + poll = relationship("Poll", back_populates="votes") + option = relationship("PollOption", back_populates="votes") def make_engine(db_cfg): """Create a SQLAlchemy engine from DatabaseConfig.""" diff --git a/models/db.py b/models/db.py index cbf073a..08bd35a 100644 --- a/models/db.py +++ b/models/db.py @@ -10,7 +10,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import base64 from sqlalchemy.orm import sessionmaker -from .base import make_engine, Messages +from .base import make_engine, Messages, Poll, PollOption, PollVote class DBfrontend: @@ -77,3 +77,142 @@ class DBfrontend: session.commit() return True return False + + def add_poll( + self, + channel: str, + creator_ip: str, + question: str, + options: list[str], + expires_at: Optional[int] = None, + is_multiple: bool = False, + is_anonymous: bool = False, + ) -> int: + with self.Session() as session: + poll = Poll( + channel=channel, + creator_ip=creator_ip, + question=self._encrypt(question), + created_at=int(time.time() * 1000), + expires_at=expires_at, + is_multiple=is_multiple, + is_anonymous=is_anonymous, + status="active", + ) + session.add(poll) + session.flush() + + for i, label in enumerate(options): + opt = PollOption(poll_id=poll.id, label=self._encrypt(label), position=i) + session.add(opt) + + session.commit() + return poll.id + + def get_polls(self, channel: str = "general") -> list: + with self.Session() as session: + polls = ( + session.query(Poll) + .filter(Poll.channel == channel, Poll.status != "deleted") + .order_by(Poll.created_at.desc()) + .limit(20) + ) + result = [] + for poll in polls.all(): + result.append(self._serialize_poll(session, poll)) + return result + + def get_poll(self, poll_id: int) -> Optional[dict]: + with self.Session() as session: + poll = session.get(Poll, poll_id) + if not poll or poll.status == "deleted": + return None + return self._serialize_poll(session, poll, include_voters=True) + + def _serialize_poll(self, session, poll, include_voters=False) -> dict: + options_data = [] + total_votes = 0 + for opt in poll.options: + vote_count = len(opt.votes) + total_votes += vote_count + voters = [v.voter_ip for v in opt.votes] if include_voters else None + options_data.append({ + "id": opt.id, + "label": self._decrypt(opt.label), + "position": opt.position, + "votes": vote_count, + "voters": voters, + }) + + return { + "id": poll.id, + "channel": poll.channel, + "creator_ip": poll.creator_ip, + "question": self._decrypt(poll.question), + "created_at": poll.created_at, + "expires_at": poll.expires_at, + "is_multiple": poll.is_multiple, + "is_anonymous": poll.is_anonymous, + "status": poll.status, + "options": options_data, + "total_votes": total_votes, + } + + def vote(self, poll_id: int, option_id: int, voter_ip: str) -> tuple[bool, str]: + with self.Session() as session: + poll = session.get(Poll, poll_id) + if not poll or poll.status != "active": + return False, "Poll is not active" + + if poll.expires_at and int(time.time() * 1000) > poll.expires_at: + poll.status = "closed" + session.commit() + return False, "Poll has expired" + + option_valid = any(opt.id == option_id for opt in poll.options) + if not option_valid: + return False, "Invalid option" + + if not poll.is_multiple: + existing = ( + session.query(PollVote) + .filter(PollVote.poll_id == poll_id, PollVote.voter_ip == voter_ip) + .first() + ) + if existing: + return False, "You have already voted in this poll" + + existing_opt = ( + session.query(PollVote) + .filter( + PollVote.poll_id == poll_id, + PollVote.option_id == option_id, + PollVote.voter_ip == voter_ip, + ) + .first() + ) + if existing_opt: + return False, "You have already voted for this option" + + vote = PollVote( + poll_id=poll_id, + option_id=option_id, + voter_ip=voter_ip, + voted_at=int(time.time() * 1000), + ) + session.add(vote) + session.commit() + return True, "Vote recorded" + + def close_poll(self, poll_id: int, requester_ip: str) -> tuple[bool, str]: + with self.Session() as session: + poll = session.get(Poll, poll_id) + if not poll: + return False, "Poll not found" + if poll.status != "active": + return False, "Poll is not active" + if poll.creator_ip != requester_ip: + return False, "Only the creator can close this poll" + poll.status = "closed" + session.commit() + return True, "Poll closed"