Initial commit
This commit is contained in:
commit
37b86d43e1
17 changed files with 480 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
.venv/
|
||||
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
instance/
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.11 (onionvote)" />
|
||||
</component>
|
||||
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/onionvote.iml" filepath="$PROJECT_DIR$/.idea/onionvote.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
10
.idea/onionvote.iml
generated
Normal file
10
.idea/onionvote.iml
generated
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (onionvote)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
60
README.md
Normal file
60
README.md
Normal file
|
@ -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 = '<the 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.
|
63
onionvote/__init__.py
Normal file
63
onionvote/__init__.py
Normal file
|
@ -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
|
28
onionvote/api.py
Normal file
28
onionvote/api.py
Normal file
|
@ -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
|
||||
}
|
73
onionvote/db.py
Normal file
73
onionvote/db.py
Normal file
|
@ -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())
|
||||
)
|
29
onionvote/index.py
Normal file
29
onionvote/index.py
Normal file
|
@ -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])
|
14
onionvote/schema.sql
Normal file
14
onionvote/schema.sql
Normal file
|
@ -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)
|
||||
);
|
63
onionvote/static/style.css
Normal file
63
onionvote/static/style.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
42
onionvote/templates/index.html
Normal file
42
onionvote/templates/index.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="utf-8">
|
||||
<title>vote4.onion</title>
|
||||
</head>
|
||||
<body>
|
||||
{% if get_flashed_messages() != [] %}
|
||||
{% for message in get_flashed_messages() %}
|
||||
<p class="flash">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="height: 10vh"></p>
|
||||
{% endif %}
|
||||
|
||||
{% if candidates | length > 2 %}
|
||||
<div id="multiContainer">
|
||||
{% for i in range(candidates | length) %}
|
||||
<a href="/vote/{{ i }}" id="cand{{ i }}" class="entry multi">
|
||||
<p>{{ candidates[i] }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="twoContainer">
|
||||
<a href="/vote/0" id="cand0" class="entry">
|
||||
<p>{{ candidates[0] }}</p>
|
||||
</a>
|
||||
|
||||
<p>vs</p>
|
||||
|
||||
<a href="/vote/1" id="cand1" class="entry">
|
||||
<p>{{ candidates[1] }}</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="/vote/-1" id="cand-1" class="entry">Indifferent</a>
|
||||
</body>
|
||||
</html>
|
44
onionvote/vote.py
Normal file
44
onionvote/vote.py
Normal file
|
@ -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('/<winner_index>', 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("/")
|
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
|
@ -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"
|
Loading…
Add table
Reference in a new issue