From 37b86d43e1ba76d9e8c589fdcd426ed3d8c7fa5a Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Mon, 10 Feb 2025 16:16:41 +0100 Subject: [PATCH] Initial commit --- .gitignore | 14 ++++ .idea/.gitignore | 3 + .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 6 ++ .idea/modules.xml | 8 ++ .idea/onionvote.iml | 10 +++ .idea/vcs.xml | 6 ++ README.md | 60 +++++++++++++++ onionvote/__init__.py | 63 ++++++++++++++++ onionvote/api.py | 28 +++++++ onionvote/db.py | 73 +++++++++++++++++++ onionvote/index.py | 29 ++++++++ onionvote/schema.sql | 14 ++++ onionvote/static/style.css | 63 ++++++++++++++++ onionvote/templates/index.html | 42 +++++++++++ onionvote/vote.py | 44 +++++++++++ pyproject.toml | 11 +++ 17 files changed, 480 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/onionvote.iml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 onionvote/__init__.py create mode 100644 onionvote/api.py create mode 100644 onionvote/db.py create mode 100644 onionvote/index.py create mode 100644 onionvote/schema.sql create mode 100644 onionvote/static/style.css create mode 100644 onionvote/templates/index.html create mode 100644 onionvote/vote.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c414af7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ + +*.pyc +__pycache__/ + +instance/ + +.pytest_cache/ +.coverage +htmlcov/ + +dist/ +build/ +*.egg-info/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c7cdccd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8b66dd2 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/onionvote.iml b/.idea/onionvote.iml new file mode 100644 index 0000000..c87b8b6 --- /dev/null +++ b/.idea/onionvote.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6d7126 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# onionvote +Vote for the best .onion (or any other kind of text) + +--- + +## Installation +Prepare: +- Python 3 +- A directory with entries. Entries are the names of files or folders in that directory. +1. Clone this repo and enter it + ```bash + git clone https://git.m724.eu/Minecon724/onionvote + cd onionvote + ``` +2. Create a virtualenv and enter it: + ```bash + python3 -m venv .venv + . .venv/bin/activate + ``` +3. Install: + ```bash + pip install -e . + ``` +4. Prepare the database: + ```bash + flask --app onionvote init-db + flask --app onionvote populate-db entries/ # replace with your entries directory + ``` +5. Run the app in **Debug mode, not for production**: + ```bash + flask --app onionvote run --debug + ``` + + +### Running in production +Follow the [Installation](#Installation) steps, then: +1. Generate a secret key: (not required, but best practice) + ```bash + python -c 'import secrets; print(secrets.token_hex())' + ``` +2. Create a file in `.venv/var/onionvote-instance/config.py` with content: + ```python + SECRET_KEY = '' + ``` +3. Install the [web server:](https://docs.pylonsproject.org/projects/waitress/en/stable/) + ```bash + pip install waitress + ``` +4. Run it: + ```bash + waitress-serve --call 'onionvote:create_app' + ``` +5. Get a reverse proxy to handle the rest. + +This was just an example, there are many ways to deploy a Flask app. See https://flask.palletsprojects.com/en/stable/deploying/ + +## Security + +[Flask sessions](https://flask.palletsprojects.com/en/stable/quickstart/#sessions) are used to make it harder to skew results. (e.g. pick your own pairs and vote many times) \ +This uses a cookie that's stored on visit and removed when the browser is closed. \ No newline at end of file diff --git a/onionvote/__init__.py b/onionvote/__init__.py new file mode 100644 index 0000000..81bda1b --- /dev/null +++ b/onionvote/__init__.py @@ -0,0 +1,63 @@ +import os + +from flask import Flask +from logging.config import dictConfig + +def create_app(test_config=None): + dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'] + } + }) + + # create and configure the app + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + SECRET_KEY='dev', + DATABASE=os.path.join(app.instance_path, 'onionvote.sqlite3'), + ENABLE_API=True + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile('config.py', silent=True) + else: + # load the test config if passed in + app.config.from_mapping(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + from . import db + db.init_app(app) + + if app.config['ENABLE_API']: + from . import api + app.register_blueprint(api.bp) + + from . import vote + app.register_blueprint(vote.bp) + + from . import index + app.register_blueprint(index.bp) + app.add_url_rule('/', endpoint='index') + + # a simple page that says hello + @app.route('/hello') + def hello(): + return 'Hello, World!' + + return app diff --git a/onionvote/api.py b/onionvote/api.py new file mode 100644 index 0000000..4d921e2 --- /dev/null +++ b/onionvote/api.py @@ -0,0 +1,28 @@ +import functools + +from flask import ( + Blueprint, flash, g, redirect, render_template, request, session, url_for +) +from werkzeug.security import check_password_hash, generate_password_hash + +from onionvote.db import get_db + +bp = Blueprint('api', __name__, url_prefix='/api') + + +@bp.route('/stats', methods=['GET']) +def stats(): + db = get_db() + + entries = db.execute( + 'SELECT COUNT(*) FROM entries' + ).fetchone()[0] + + votes = db.execute( + 'SELECT COUNT(*) FROM votes' + ).fetchone()[0] + + return { + "entries": entries, + "votes": votes + } diff --git a/onionvote/db.py b/onionvote/db.py new file mode 100644 index 0000000..def52e3 --- /dev/null +++ b/onionvote/db.py @@ -0,0 +1,73 @@ +import sqlite3 +from datetime import datetime + +import click +from flask import current_app, g + +from os import listdir + + +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect( + current_app.config['DATABASE'], + detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + +def close_db(e=None): + db = g.pop('db', None) + + if db is not None: + db.close() + + +def init_db(): + db = get_db() + + with current_app.open_resource('schema.sql') as f: + db.executescript(f.read().decode('utf8')) + + +def load_entries_from_directory(dir: str) -> int: + db = get_db() + i = 0 + + for e in listdir(dir): + db.execute("INSERT OR IGNORE INTO entries (entry) VALUES (?)", (e,)) + print(e) + print("\033[A", end='') + i += 1 + + print("\033[K", end='') + db.commit() + return i + + +@click.command('init-db') +def init_db_command(): + """Clear the existing data and create new tables.""" + init_db() + click.echo('Initialized the database.') + +@click.command('populate-db') +@click.argument("dir") +def populate_db_command(dir: str): + """Populate the database - load entries from a directory""" + i = load_entries_from_directory(dir) + click.echo(f'Inserted {i} entries.') + + + +def init_app(app): + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) + app.cli.add_command(populate_db_command) + + +sqlite3.register_converter( + "timestamp", lambda v: datetime.fromisoformat(v.decode()) +) diff --git a/onionvote/index.py b/onionvote/index.py new file mode 100644 index 0000000..939656b --- /dev/null +++ b/onionvote/index.py @@ -0,0 +1,29 @@ +from flask import ( + Blueprint, flash, g, redirect, render_template, request, url_for, session, current_app +) +from werkzeug.exceptions import abort + +from onionvote.db import get_db + +bp = Blueprint('index', __name__) + + +@bp.route('/') +def index(): + db = get_db() + limit = 2 + + candidates = db.execute( + 'SELECT id, entry FROM entries' + ' ORDER BY RANDOM() LIMIT ?', (limit,) + ).fetchall() + + voted = session.get("voted") + if voted is None: + flash("Click on a URL to vote for it!") + + session['cand'] = [c[0] for c in candidates] + + current_app.logger.debug(f"New session: {', '.join([str(c[0]) for c in candidates])} | This user voted {voted} times") + + return render_template('index.html', multi=False, candidates=[c[1] for c in candidates]) diff --git a/onionvote/schema.sql b/onionvote/schema.sql new file mode 100644 index 0000000..8dfc73a --- /dev/null +++ b/onionvote/schema.sql @@ -0,0 +1,14 @@ +DROP TABLE IF EXISTS entries; +DROP TABLE IF EXISTS votes; + +CREATE TABLE entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry TEXT UNIQUE NOT NULL +); + +CREATE TABLE votes ( + winner_id INTEGER NOT NULL, + loser_id INTEGER NOT NULL, + FOREIGN KEY (winner_id) REFERENCES entries (id), + FOREIGN KEY (loser_id) REFERENCES entries (id) +); \ No newline at end of file diff --git a/onionvote/static/style.css b/onionvote/static/style.css new file mode 100644 index 0000000..c1804d3 --- /dev/null +++ b/onionvote/static/style.css @@ -0,0 +1,63 @@ +body { + margin: 0; + width: 100vw; + + background: black; + color: white; + + font-size: large; + text-align: center; +} + +.flash:first-child { + margin-top: 10vh; +} + +.entry { + width: fit-content; + margin: 0 auto; + display: flex; + + font-family: monospace; + + text-decoration: none; + color: inherit; + + white-space: normal; + word-break: break-all; + text-align: right; + + &:hover { + color: yellow; + } +} + +.entry:hover { + color: yellow; +} + +#cand-1 { + padding: 20px; +} + +#twoContainer > .entry { + height: 10vh; + + &:first-child { + align-items: flex-end; + } + + &:last-child { + align-items: flex-start; + } +} + +#multiContainer > .entry { + &:first-child { + margin-top: 10vh; + } + + &:last-child { + margin-bottom: 5vh; + } +} \ No newline at end of file diff --git a/onionvote/templates/index.html b/onionvote/templates/index.html new file mode 100644 index 0000000..b06fabf --- /dev/null +++ b/onionvote/templates/index.html @@ -0,0 +1,42 @@ + + + + + + + vote4.onion + + + {% if get_flashed_messages() != [] %} + {% for message in get_flashed_messages() %} +

{{ message }}

+ {% endfor %} + {% else %} +

+ {% endif %} + + {% if candidates | length > 2 %} +
+ {% for i in range(candidates | length) %} + +

{{ candidates[i] }}

+
+ {% endfor %} +
+ {% else %} + + {% endif %} + + Indifferent + + \ No newline at end of file diff --git a/onionvote/vote.py b/onionvote/vote.py new file mode 100644 index 0000000..712efb1 --- /dev/null +++ b/onionvote/vote.py @@ -0,0 +1,44 @@ +import functools + +from flask import ( + Blueprint, flash, g, redirect, render_template, request, session, url_for, config, current_app, abort +) +from werkzeug.security import check_password_hash, generate_password_hash + +from onionvote.db import get_db + +bp = Blueprint('vote', __name__, url_prefix='/vote') + + +@bp.route('/', methods=['GET']) +def vote(winner_index): + try: + winner_index = int(winner_index) + except ValueError: + abort(400) + + if winner_index == -1: + # TODO maybe track that? + return redirect("/") + + cand = session.pop("cand") + session['voted'] = 1 + + if cand is None: + print("Attempted to vote without session") + return redirect("/") + + winner_id = cand[winner_index] + + db = get_db() + + for loser_id in cand: + if loser_id == winner_id: + continue + db.execute("INSERT INTO votes (winner_id, loser_id) VALUES (?, ?)", (winner_id, loser_id)) + + current_app.logger.debug(f"Vote: {winner_id} > {loser_id}") + + db.commit() + + return redirect("/") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..86c0eb1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "onionvote" +version = "1.0.0" +description = "Vote for the best text string" +dependencies = [ + "flask", +] + +[build-system] +requires = ["flit_core >= 3.4"] +build-backend = "flit_core.buildapi" \ No newline at end of file