diff --git a/.gitignore b/.gitignore index 6e3af5b..a8e852f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ build/ **/*.egg-info **/__pycache__ +mounts/ +**/*.pem +**/*.secret diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd0369c --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ + +# Development + +## JWT Key pair generation + +JWT generation uses an RSA public-private key pair. To generate these keys run: + +```console +$ openssl genrsa -out private-key.pem 4096 +$ openssl rsa -in private-key.pem -pubout > public-key.pem +``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..0a297b9 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,13 @@ +services: + fnb-website: + build: . + ports: + - 1312:1312 + fnb-website-database: + image: mongodb/mongodb-community-server:8.0-ubi8 + volumes: + - type: bind + source: ./mounts/database + target: /data/db + ports: + - 27017:27017 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 519c092..ed7771c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,19 @@ services: - fnb-redirecter: + fnb-website: build: . labels: + traefik.enable: true traefik.http.routers.fnb-redirecter.rule: Host(`rocfnb.org`) traefik.http.routers.fnb-redirecter.tls.certresolver: letsencrypt_standalone - networks: [ public ] \ No newline at end of file + networks: [ public, fnb-website ] + fnb-website-database: + image: mongodb/mongodb-community-server:8.0-ubi8 + networks: [ fnb-website ] + volumes: + - type: bind + source: ./mounts/database + target: /data/db + +networks: + fnb-website: + internal: true \ No newline at end of file diff --git a/fnb_redirecter/__init__.py b/fnb_redirecter/__init__.py deleted file mode 100644 index 2f31474..0000000 --- a/fnb_redirecter/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from fnb_redirecter.server import app \ No newline at end of file diff --git a/fnb_redirecter/__main__.py b/fnb_redirecter/__main__.py deleted file mode 100644 index 6264389..0000000 --- a/fnb_redirecter/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from os import execlp - -execlp('gunicorn', 'gunicorn', '--conf', '/app/wsgi-conf.py', '--bind', '0.0.0.0:1312', 'fnb_redirecter:app') \ No newline at end of file diff --git a/fnb_redirecter/server.py b/fnb_redirecter/server.py deleted file mode 100644 index 2967ca7..0000000 --- a/fnb_redirecter/server.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import Flask, redirect - -app = Flask(__name__.split('.')[0]) - -@app.route('/ig') -def ig_redir(): - return redirect('https://instagram.com/RocFNB') - -@app.route('/donate') -def donate_redir(): - return redirect('https://venmo.com/RocFoodNotBombs') - -@app.errorhandler(404) -def redirect_other(_): - return redirect('https://linktr.ee/RocFNB') - diff --git a/pyproject.toml b/pyproject.toml index 6f7bb0b..55440e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,29 @@ requires = ["setuptools", "setuptools-scm"] build-backend = "setuptools.build_meta" [project] -name = "fnb_redirecter" +name = "roc_fnb_website" authors = [{ name = "D. Scott Boggs", email = "scott@techwork.zone" }] description = "Temporary placeholder for fnb web site" readme = "README.md" requires-python = ">=3.11" # Self type added license = "AGPL-3.0-only" -dependencies = ["flask", "gunicorn"] +dependencies = [ + "flask", + "gunicorn", + "structlog", + "pymongo", + "scrypt", + "pyjwt", + + # For tests + "pytest" +] dynamic = ["version"] -[project.scripts] -# Put scripts here +[tool.setuptools] +# This is here because the top-level mounts directory gets interpreted by +# setuptools to be a module without it. +packages = ["roc_fnb"] + +[tool.yapf] +based_on_style = "facebook" diff --git a/roc_fnb/__init__.py b/roc_fnb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/__main__.py b/roc_fnb/__main__.py new file mode 100644 index 0000000..c45611b --- /dev/null +++ b/roc_fnb/__main__.py @@ -0,0 +1,3 @@ +from os import execlp + +execlp('gunicorn', 'gunicorn', '--conf', './wsgi-conf.py', '--bind', '0.0.0.0:1312', 'roc_fnb.website:app') \ No newline at end of file diff --git a/roc_fnb/scripts/__init__.py b/roc_fnb/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/scripts/bootstrap_first_admin.py b/roc_fnb/scripts/bootstrap_first_admin.py new file mode 100644 index 0000000..8aef1ea --- /dev/null +++ b/roc_fnb/scripts/bootstrap_first_admin.py @@ -0,0 +1,24 @@ +from click import command, option, prompt, confirm +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + + +@command +@option('--name', '-n', type=str, required=True) +@option('--email', '-e', type=str, required=True) +def bootstrap_first_admin(name: str, email: str): + password = prompt('Enter the account password', + hide_input=True, prompt_suffix=': ') + confirmation = prompt('Confirm the account password', + hide_input=True, prompt_suffix=': ') + if password != confirmation: + raise ValueError('passwords did not match') + admin = User.create(email, name, password, moderator=True, admin=True) + db = Database.from_env() + db.store_user(admin) + if confirm('Display an auth token for testing?', default=False): + print(admin.jwt) + + +if __name__ == '__main__': + bootstrap_first_admin() diff --git a/roc_fnb/scripts/run-tests.sh b/roc_fnb/scripts/run-tests.sh new file mode 100755 index 0000000..0ae4cca --- /dev/null +++ b/roc_fnb/scripts/run-tests.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +python -m pytest --capture no --ignore mounts \ No newline at end of file diff --git a/roc_fnb/util/__init__.py b/roc_fnb/util/__init__.py new file mode 100644 index 0000000..0be7677 --- /dev/null +++ b/roc_fnb/util/__init__.py @@ -0,0 +1 @@ +from roc_fnb.util.logging import log \ No newline at end of file diff --git a/roc_fnb/util/base64.py b/roc_fnb/util/base64.py new file mode 100644 index 0000000..293dd72 --- /dev/null +++ b/roc_fnb/util/base64.py @@ -0,0 +1,30 @@ +from base64 import b64encode +from binascii import a2b_base64 +import binascii + + +def base64_decode(string: str|bytes) -> bytes: + """ + Python's base64.b64_decode requires padding to be correct. This does not. + + What silly bullshit. + """ + encoded: bytes = string.encode() if isinstance(string, str) else string + # padc = 4 - len(encoded) % 4 + # if padc == 4: + # padc = 0 + # fixed = encoded + b'=' * padc + fixed = encoded + b'====' + try: + return a2b_base64(fixed, strict_mode=False) + except binascii.Error as e: + print(f'{string=!r}\n{len(string)=!r}\n{padc=!r}\n{fixed=!r}\n{len(fixed)=!r}') + raise e + +def base64_encode(data: bytes) -> str: + """ + Return a base64 encoded string with no padding + + Python's b64encode returns bytes. Why? + """ + return b64encode(data).decode('utf-8').rstrip('=') \ No newline at end of file diff --git a/roc_fnb/util/env_file.py b/roc_fnb/util/env_file.py new file mode 100644 index 0000000..c07baae --- /dev/null +++ b/roc_fnb/util/env_file.py @@ -0,0 +1,29 @@ +from os import environ + + +def env_file(key, default_file=KeyError, default=KeyError, default_fn=KeyError): + """ + Return a value from an environment variable or file specified by one. + + Checks first for the value specified by key with "_FILE" appended. If that + is found, read from the file there. Otherwise return the value of the + environment variable, the contents of the specified default file, the default + value, or raises KeyError. + """ + if fp := environ.get(f'{key}_FILE'): + with open(fp) as file: + return file.read() + if var := environ.get(key): + return var + if default_file is not KeyError: + try: + with open(default_file) as file: + return file.read() + except FileNotFoundError: + ... # fallthrough + if default is not KeyError: + return default + if default_fn is not KeyError: + return default_fn() + raise KeyError(f'no environment variable found ${key} nor {key}_FILE and default was not specified') + \ No newline at end of file diff --git a/roc_fnb/util/logging.py b/roc_fnb/util/logging.py new file mode 100644 index 0000000..b98508a --- /dev/null +++ b/roc_fnb/util/logging.py @@ -0,0 +1,30 @@ +import logging +from os import environ + +from structlog.processors import JSONRenderer, TimeStamper +from structlog.dev import ConsoleRenderer +import structlog + +if not structlog.is_configured(): + if (env := environ.get('ENV_MODE')) and env == 'production': + timestamper = TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=True) + renderer: JSONRenderer | ConsoleRenderer = JSONRenderer() + else: + timestamper = TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False) + renderer = ConsoleRenderer() + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + timestamper, + renderer, + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=False + ) + +log = structlog.get_logger() diff --git a/roc_fnb/util/test_base64.py b/roc_fnb/util/test_base64.py new file mode 100644 index 0000000..270886b --- /dev/null +++ b/roc_fnb/util/test_base64.py @@ -0,0 +1,11 @@ +from random import randbytes + +from roc_fnb.util.base64 import * + + +def test_encode_and_decode(): + for size in range(20-40): + data = randbytes(32) + string = base64_encode(data) + decoded = base64_decode(string) + assert data == decoded \ No newline at end of file diff --git a/roc_fnb/website/__init__.py b/roc_fnb/website/__init__.py new file mode 100644 index 0000000..b0e5ce3 --- /dev/null +++ b/roc_fnb/website/__init__.py @@ -0,0 +1 @@ +from roc_fnb.website.server import app diff --git a/roc_fnb/website/database.py b/roc_fnb/website/database.py new file mode 100644 index 0000000..f65a7c1 --- /dev/null +++ b/roc_fnb/website/database.py @@ -0,0 +1,60 @@ +from os import environ +from typing import Optional, Self +from pdb import set_trace as debugger + +from bson.objectid import ObjectId +import pymongo +from pymongo import MongoClient + +from roc_fnb.website.models.user import User + +KEYLEN = 64 +with open('private-key.pem') as pk: + PRIVATE_KEY = pk.read() +with open('public-key.pem') as pk: + PUBLIK_KEY = pk.read() + + +class Database(MongoClient): + @classmethod + def from_env(cls) -> Self: + return cls( + host=environ.get('DATABASE_HOST', default='localhost'), + port=int(environ.get('DATABASE_PORT', default=27017)) + ) + + @property + def db(self) -> pymongo.database.Database: + if (env := environ.get('ENV_MODE')) and env == 'production': + return self.production + else: + return self.development + + def store_user(self, user: User) -> User: + """Store the given user in the database, set _id on it, and return it""" + result = self.db.users.insert_one(user.document) + user._id = result.inserted_id + return user + + def get_user_by_email(self, email: str) -> Optional[User]: + """ + Return the user associated with the given email. + + This does not imply authentiation + """ + if result := self.db.users.find_one({'email': email}): + return User(**result) + return None + + def get_user_by_name(self, name: str) -> Optional[User]: + if result := self.db.users.find_one({'name': name}): + return User(**result) + return None + + def delete_user(self, _id: ObjectId): + self.db.users.delete_one({'_id': _id}) + + def get_user_by_id(self, id: ObjectId) -> Optional[User]: + if user := self.db.users.find_one({'_id': id}): + return User(**user) + return None diff --git a/roc_fnb/website/models/__init__.py b/roc_fnb/website/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roc_fnb/website/models/test_user.py b/roc_fnb/website/models/test_user.py new file mode 100644 index 0000000..74a4b37 --- /dev/null +++ b/roc_fnb/website/models/test_user.py @@ -0,0 +1,50 @@ +import json +from random import randbytes + +from bson.objectid import ObjectId +from pytest import fixture + +from roc_fnb.util.base64 import base64_decode +from roc_fnb.website.database import Database +from roc_fnb.website.models.user import User + + +@fixture +def user() -> User: + return User.create('test@t.co', 'name', 'monkey') + + +@fixture +def database() -> Database: + return Database.from_env() + + +def test_user_and_check_password(user): + assert user.name == 'name' + assert user.email == 'test@t.co' + assert user._id is None + assert user.check_password('monkey') + + +def test_store_and_retreive(user: User, database: Database): + try: + database.store_user(user) + assert user._id is not None + retreived = database.get_user_by_email(user.email) + assert retreived is not None + assert retreived._id == user._id + assert retreived == user + finally: + if id := user._id: + database.delete_user(id) + + +def test_store_and_retreive_by_id(user: User, database: Database): + try: + database.store_user(user) + assert user._id is not None + retreived = database.get_user_by_id(user._id) + assert retreived == user + finally: + if id := user._id: + database.delete_user(id) diff --git a/roc_fnb/website/models/user.py b/roc_fnb/website/models/user.py new file mode 100644 index 0000000..6c52ee7 --- /dev/null +++ b/roc_fnb/website/models/user.py @@ -0,0 +1,86 @@ +from base64 import b64decode, b64encode +from dataclasses import dataclass +import json +from random import randbytes +from typing import Optional, Any, Self + +from bson.objectid import ObjectId +import scrypt +import jwt + +from roc_fnb.util.base64 import base64_encode, base64_decode + +with open('private-key.pem') as file: + PRIVATE_KEY = file.read() + +with open('public-key.pem') as file: + PUBLIC_KEY = file.read() + +@dataclass +class JwtUser: + _id: ObjectId + email: str + name: str + moderator: bool + admin: bool + + @classmethod + def from_json(cls, data: dict) -> Self: + _id = ObjectId(base64_decode(data.pop('_id'))) + return cls(_id=_id, **data) + + +@dataclass +class User: + _id: Optional[ObjectId] + email: str + name: str + password_hash: bytes + salt: bytes + moderator: bool + admin: bool + + @classmethod + def create(cls, email: str, name: str, password: str|bytes, moderator: bool = False, admin: bool = False): + """Alternate constructor which hashes a given password""" + salt = randbytes(32) + password_hash = scrypt.hash(password, salt) + return cls(_id=None, + email=email, + name=name, + password_hash=password_hash, + salt=salt, + moderator=moderator, + admin=admin) + + @property + def document(self): + doc = { + "email": self.email, + "name": self.name, + "password_hash": self.password_hash, + "salt": self.salt, + "moderator": self.moderator, + "admin": self.admin, + } + if self._id is not None: + doc['_id'] = self._id + return doc + + @property + def public_fields(self): + """ + Session data is visible to client scripts. + + This is a feature, not a bug; client scripts may need to gather login info. + """ + return { + '_id': base64_encode(self._id.binary), + "email": self.email, + "name": self.name, + "moderator": self.moderator, + "admin": self.admin, + } + + def check_password(self, password: str) -> bool: + return self.password_hash == scrypt.hash(password, self.salt) diff --git a/roc_fnb/website/server/__init__.py b/roc_fnb/website/server/__init__.py new file mode 100644 index 0000000..862f9a2 --- /dev/null +++ b/roc_fnb/website/server/__init__.py @@ -0,0 +1 @@ +from roc_fnb.website.server.server import app \ No newline at end of file diff --git a/roc_fnb/website/server/decorators.py b/roc_fnb/website/server/decorators.py new file mode 100644 index 0000000..b099f14 --- /dev/null +++ b/roc_fnb/website/server/decorators.py @@ -0,0 +1,38 @@ +""" +Various decorators which may be applied to specific routes + +See https://stackoverflow.com/a/51820573 for reference. +""" +from flask import g, abort, request +from functools import wraps + + +def require_user(admin = False, moderator = False): + """A decorator for any routes which require authentication.""" + def _require_user(handler): + @wraps(handler) + def __require_user(): + if getattr(g, 'user', None) is None \ + or (admin and not g.user.admin) \ + or (moderator and not g.user.moderator): + abort(401) + return handler() + return __require_user + return _require_user + + +def logger_request_bindings(log): + """Applied to a route which logs something to have request data bound to the logger.""" + def _lrb(handler): + @wraps(handler) + def __lrb(): + log.bind( + path=request.path, + method=request.method, + user_agent=request.user_agent, + remote_ip=request.remote_addr, + user=getattr(g, 'user', '(anonymous)'), + ) + return handler(log) + return __lrb + return _lrb \ No newline at end of file diff --git a/roc_fnb/website/server/server.py b/roc_fnb/website/server/server.py new file mode 100644 index 0000000..a72c16e --- /dev/null +++ b/roc_fnb/website/server/server.py @@ -0,0 +1,46 @@ +from functools import wraps +import json +from pathlib import Path +from random import randbytes +from sys import stderr + +from flask import Flask, redirect + +from roc_fnb.util.env_file import env_file +from roc_fnb.util import log +from roc_fnb.website.database import Database +from roc_fnb.website.server.user import setup_user_routes + + +db = Database.from_env() + +app = Flask( + import_name=__name__.split('.')[0], + static_url_path='/', + template_folder=Path(__file__).absolute().parent / 'templates', + static_folder=Path(__file__).absolute().parent / 'static', +) + +app.secret_key = env_file('FLASK_SECRET', default_file='./flask.secret', default_fn=lambda: randbytes(12)) + +@app.before_request +def decode_user(): + if user := session.get('user'): + g.user = JwtUser.from_json(data=json.loads(user)) + + +@app.route('/ig') +def ig_redir(): + return redirect('https://instagram.com/RocFNB') + + +@app.route('/donate') +def donate_redir(): + return redirect('https://venmo.com/RocFoodNotBombs') + + +@app.route('/') +def index(): + return redirect('/index.html') + +setup_user_routes(app, db) \ No newline at end of file diff --git a/roc_fnb/website/server/user.py b/roc_fnb/website/server/user.py new file mode 100644 index 0000000..50ddc20 --- /dev/null +++ b/roc_fnb/website/server/user.py @@ -0,0 +1,31 @@ +import json + +from flask import request, redirect, render_template, g, abort + +from roc_fnb.util import log +from roc_fnb.website.server.decorators import require_user, logger_request_bindings + +def setup_user_routes(app, db): + @app.post('/login') + @logger_request_bindings(log) + def submit_login(log): + form = request.json + log.info('user attempting login', name=form.get('name')) + user = db.get_user_by_name(form['name']) + if not user.check_password(form['password']): + log.warn('incorrect password submitted', name=form['name']) + abort(401) # unauthorized + session['user'] = json.dumps(user.public_fields) + return redirect('/me') + + @app.get('/login') + def render_login_page(): + if getattr(g, 'user', None): + log.debug('user is already logged in', user=g.user) + return redirect('/me') + return render_template('login.html') + + @app.get('/me') + @require_user() + def get_profile(): + return render_template('profile.html', user=g.user) \ No newline at end of file diff --git a/roc_fnb/website/static/blog.html b/roc_fnb/website/static/blog.html new file mode 100644 index 0000000..7893e48 --- /dev/null +++ b/roc_fnb/website/static/blog.html @@ -0,0 +1,31 @@ + + +
+ +
+Free hot meals are served every Saturday at 6:30PM in front of the RTS Transit Center on St. Paul.
+Help us cook every Saturday at 4PM at the Flying Squirrel Community Space. +
+Rochester Food Not Bombs recovers resources to create free vegetarian and vegan meals in our local + community. + We + are a decentralized all-volunteer run group, with no hierarchy or formal leaders, making decisions based + on + consensus.
++ We recognize poverty as a form of violence, and think access to food should be viewed as a right, rather + than a + privilege. In this practice, we strive to reduce waste by turning donated food into productive meals. We + source + food from vendors at the Public Market, as well as relying on donations. Food should be a source of + nutrition + for + people, not profit under capitalism. Through Community organizing and outreach, we support other local + and + national peace and justice groups to create a broader sense of social responsibility -- If you would + like to + get + involved, please reach out to us! +
+
+
+This will be the profile/settings page for {{user.name}}
+{% endblock %} \ No newline at end of file