diff --git a/.forgejo/workflows/run-tests.yml b/.forgejo/workflows/run-tests.yml deleted file mode 100644 index bae9afa..0000000 --- a/.forgejo/workflows/run-tests.yml +++ /dev/null @@ -1,6 +0,0 @@ -on: [push] -jobs: - run-tests: - runs-on: docker - steps: - - run: echo it works! diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index f1ad263..0000000 --- a/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "arrowParens": "avoid", - "semi": false, - "singleQuote": true -} \ No newline at end of file diff --git a/roc_fnb/scripts/clear_test_users.py b/roc_fnb/scripts/clear_test_users.py deleted file mode 100644 index c3bafd5..0000000 --- a/roc_fnb/scripts/clear_test_users.py +++ /dev/null @@ -1,6 +0,0 @@ -from roc_fnb.website.database import Database - -db = Database.from_env() - -db.db.users.delete_many({}) -db.db.invitations.delete_many({}) \ No newline at end of file diff --git a/roc_fnb/scripts/run-tests.sh b/roc_fnb/scripts/run-tests.sh index 7be23b0..0ae4cca 100755 --- a/roc_fnb/scripts/run-tests.sh +++ b/roc_fnb/scripts/run-tests.sh @@ -1,3 +1,2 @@ #!/usr/bin/env bash -export APP_HOSTNAME=localhost:1312 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 index d915ec5..0be7677 100644 --- a/roc_fnb/util/__init__.py +++ b/roc_fnb/util/__init__.py @@ -1,2 +1 @@ -from roc_fnb.util.logging import log -from roc_fnb.util.base64 import base64_decode, base64_encode \ No newline at end of file +from roc_fnb.util.logging import log \ No newline at end of file diff --git a/roc_fnb/website/database.py b/roc_fnb/website/database.py index dc3a96f..f65a7c1 100644 --- a/roc_fnb/website/database.py +++ b/roc_fnb/website/database.py @@ -1,6 +1,4 @@ -from datetime import datetime, UTC from os import environ -from random import randbytes from typing import Optional, Self from pdb import set_trace as debugger @@ -8,9 +6,7 @@ from bson.objectid import ObjectId import pymongo from pymongo import MongoClient -from roc_fnb.util.base64 import base64_encode -from roc_fnb.util import log -from roc_fnb.website.models.user import User, JwtUser +from roc_fnb.website.models.user import User KEYLEN = 64 with open('private-key.pem') as pk: @@ -62,42 +58,3 @@ class Database(MongoClient): if user := self.db.users.find_one({'_id': id}): return User(**user) return None - - def invite_new_user(self, inviter: JwtUser | User) -> bytes: - """Save a new invite code to the database and return its raw bytes.""" - if inviter_id := inviter._id: - invite_code = randbytes(32) - self.db.invitations.insert_one({ - 'created_at': datetime.now(UTC).timestamp(), - 'invited_user_id': None, - 'invite_code': invite_code, - 'invited_by': inviter_id, - }) - return invite_code - raise ValueError('unsaved user cannot create an invite') - - def store_new_invitee(self, invite_code: bytes, invitee: User) -> User: - """Store the new user if the given invite_code exists and is not already used.""" - if invite := self.db.invitations.find_one({'invite_code': invite_code}): - if invite.get('invited_user_id') is None: - self.store_user(invitee) - self.db.invitations.update_one({'_id': invite['_id']}, {'$set': {'invited_user_id': invitee._id}}) - return invitee - raise InvitationClaimed(invite, invitee) - raise InvitationNotFound(invite_code, invitee) -# -class InvitationClaimed(ValueError): - def __init__(self, invitation: dict, invitee: User): - self.invitation = invitation - self.invitiee = invitee - emsg = f'{invitee.name} tried to sign up with a used invite code: {base64_encode(invitation["invite_code"])}' - log.error(emsg, invitation=self.invitation, invitee=invitee) - super().__init__(emsg) - -class InvitationNotFound(ValueError): - def __init__(self, invitation_code: bytes, invitee: User): - self.invitation_code = base64_encode(invitation_code) - self.invitiee = invitee - emsg = f'{invitee.name} tried to sign up with an invalid invite code: {self.invitation_code}' - log.error(emsg, invitation_code=self.invitation_code, invitee=invitee) - super().__init__(emsg) \ No newline at end of file diff --git a/roc_fnb/website/models/user.py b/roc_fnb/website/models/user.py index bfefcd9..6c52ee7 100644 --- a/roc_fnb/website/models/user.py +++ b/roc_fnb/website/models/user.py @@ -16,7 +16,6 @@ with open('private-key.pem') as file: with open('public-key.pem') as file: PUBLIC_KEY = file.read() - @dataclass class JwtUser: _id: ObjectId @@ -42,26 +41,17 @@ class User: admin: bool @classmethod - def create( - cls, - email: str, - name: str, - password: str | bytes, - moderator: bool = False, - admin: bool = False, - ): + 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, - ) + return cls(_id=None, + email=email, + name=name, + password_hash=password_hash, + salt=salt, + moderator=moderator, + admin=admin) @property def document(self): diff --git a/roc_fnb/website/server/server.py b/roc_fnb/website/server/server.py index e715a25..a72c16e 100644 --- a/roc_fnb/website/server/server.py +++ b/roc_fnb/website/server/server.py @@ -1,17 +1,16 @@ from functools import wraps import json -from os import environ from pathlib import Path from random import randbytes from sys import stderr -from flask import Flask, redirect, session, g +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 -from roc_fnb.website.models.user import JwtUser + db = Database.from_env() @@ -22,20 +21,13 @@ app = Flask( static_folder=Path(__file__).absolute().parent / 'static', ) -app.secret_key = env_file( - 'FLASK_SECRET', - default_file='./flask.secret', - default_fn=lambda: randbytes(12) -) -APP_HOSTNAME = environ["APP_HOSTNAME"] - +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)) - g.app_hostname = APP_HOSTNAME - + @app.route('/ig') def ig_redir(): @@ -51,5 +43,4 @@ def donate_redir(): def index(): return redirect('/index.html') - -setup_user_routes(app, db) +setup_user_routes(app, db) \ No newline at end of file diff --git a/roc_fnb/website/server/test_user.py b/roc_fnb/website/server/test_user.py deleted file mode 100644 index a8cbcbf..0000000 --- a/roc_fnb/website/server/test_user.py +++ /dev/null @@ -1,129 +0,0 @@ -from bson.objectid import ObjectId -from flask.testing import FlaskClient -import json -from pytest import fixture - -from roc_fnb.util import base64_decode -from roc_fnb.website.server import app -from roc_fnb.website.models.user import User -from roc_fnb.website.database import Database - - -@fixture -def user() -> User: - return User.create('test@t.co', 'name', 'monkey') - - -@fixture -def database() -> Database: - return Database.from_env() - - -@fixture -def client(): - return app.test_client() - - -def test_login(user: User, database: Database, client: FlaskClient): - database.store_user(user) - try: - assert user._id is not None # for mypy - resp = client.post( - '/login', json={ - 'name': user.name, - 'password': 'monkey' - } - ) - assert resp.json is not None # for mypy - assert resp.json['status'] == "OK" - with client.session_transaction() as session: - session_data = json.loads(session['user']) - assert base64_decode(session_data['_id']) == user._id.binary - assert session_data['name'] == user.name - assert 'password' not in session_data.keys() - assert session_data['email'] == user.email - assert not session.get('admin') - assert not session.get('moderator') - finally: - if _id := user._id: - database.delete_user(_id) - - -def test_create_user(user: User, database: Database, client: FlaskClient): - user.admin = True - try: - database.store_user(user) - assert user._id is not None # for mypy - resp = client.post( - '/login', json={ - 'name': user.name, - 'password': 'monkey' - } - ) - assert resp.json is not None # for mypy - assert resp.json['status'] == "OK" - with client.session_transaction() as session: - assert session.get('user') is not None - session_data = json.loads(session['user']) - assert session_data['admin'] - resp = client.get('/invite', headers={'Accept': 'application/json'}) - assert resp.status_code == 200 - assert resp.json is not None - invite = resp.json['invite'] - - with client.session_transaction() as session: - del session['user'] - _id = None - try: - resp = client.post( - '/new-user', - json={ - 'name': 'Test 2', - 'password': 'hunter2', - 'invite_code': invite, - 'email': None - } - ) - assert resp.status_code == 200 - assert resp.json is not None - assert resp.json['status'] == 'OK' - with client.session_transaction() as session: - session_data = json.loads(session['user']) - _id = ObjectId(base64_decode(session_data['_id'])) - new_user = database.get_user_by_id(_id) - assert new_user is not None - assert new_user._id == _id - assert new_user.name == 'Test 2' - assert new_user.check_password('hunter2') - assert not (new_user.admin or new_user.moderator) - invitation_doc = database.db.invitations.find_one({'invite_code': base64_decode(invite)}) - assert invitation_doc is not None - assert invitation_doc['invited_user_id'] == _id - assert invitation_doc['invited_by'] == user._id - finally: - if _id is not None: - database.delete_user(_id) - finally: - if u_id := user._id: - database.delete_user(u_id) - - -def test_create_user_deny_non_admin(user: User, database: Database, client: FlaskClient): - database.store_user(user) - try: - assert user._id is not None # for mypy - resp = client.post( - '/login', json={ - 'name': user.name, - 'password': 'monkey' - } - ) - assert resp.json is not None # for mypy - assert resp.json['status'] == "OK" - with client.session_transaction() as session: - assert session.get('user') is not None - resp = client.get('/invite', headers={'Accept': 'application/json'}) - assert resp.status_code == 401 - finally: - if _id := user._id: - database.delete_user(_id) \ No newline at end of file diff --git a/roc_fnb/website/server/user.py b/roc_fnb/website/server/user.py index f91025b..50ddc20 100644 --- a/roc_fnb/website/server/user.py +++ b/roc_fnb/website/server/user.py @@ -1,12 +1,9 @@ import json -from flask import request, redirect, render_template, g, abort, make_response, flash, jsonify, session +from flask import request, redirect, render_template, g, abort -from roc_fnb.util import log, base64_encode, base64_decode +from roc_fnb.util import log from roc_fnb.website.server.decorators import require_user, logger_request_bindings -from roc_fnb.website.database import InvitationClaimed, InvitationNotFound -from roc_fnb.website.models.user import User - def setup_user_routes(app, db): @app.post('/login') @@ -19,7 +16,7 @@ def setup_user_routes(app, db): log.warn('incorrect password submitted', name=form['name']) abort(401) # unauthorized session['user'] = json.dumps(user.public_fields) - return jsonify(status='OK') + return redirect('/me') @app.get('/login') def render_login_page(): @@ -31,72 +28,4 @@ def setup_user_routes(app, db): @app.get('/me') @require_user() def get_profile(): - return render_template('profile.html', user=g.user) - - @app.get('/invite') - @require_user(admin=True) - def create_invite(): - """ - Two handlers in one: JSON and HTML. - - First, a user-agent (browser) makes a request for /invite, and gets the - rendered HTML page. Then they click a button which sends a request - specifically asking for a JSON reply. The invitation is created and - returned in a JSON document. - - This allows a user to generate more than one invitation code per visit - to the page and avoids accidentally creating an invite code on page load. - """ - if request.headers['Accept'] == 'application/json': - invite = base64_encode(db.invite_new_user(g.user)) - log.info('new invitation created', inviter=g.user, invitation_code=invite) - return jsonify(invite=invite, status='OK') - return render_template( - 'new_invite.html', user=g.user, app_hostname=g.app_hostname - ) - - @app.post('/new-user') - def create_new_user(): - decoded_invite_code = base64_decode(request.json['invite_code']) - invitee = User.create( - request.json['email'], request.json['name'], - request.json['password'] - ) - try: - db.store_new_invitee(decoded_invite_code, invitee) - log.info('new user created', user=invitee, invite_code=request.json['invite_code']) - except InvitationClaimed as err: - response = make_response( - json.dumps( - { - 'type': - 'InvitationClaimed', - 'invite_code': - base64_encode(err.invitation['invite_code']), - 'message': - str(err) - } - ) - ) - response.headers['Content-Type'] = 'application/json' - response.status_code = 401 - abort(response) - except InvitationNotFound as err: - r = make_response( - json.dumps( - { - 'type': 'InvitationNotFound', - 'invite_code': err.invitation_code, - 'message': str(err) - } - ) - ) - response.headers['Content-Type'] = 'application/json' - response.status_code = 404 - abort(response) - session['user'] = json.dumps(invitee.public_fields) - return jsonify(status='OK') - - @app.get('/new-user') - def render_signup_page(): - return render_template('sign-up.html') + return render_template('profile.html', user=g.user) \ No newline at end of file diff --git a/roc_fnb/website/templates/new_invite.html b/roc_fnb/website/templates/new_invite.html deleted file mode 100644 index 20ea3a6..0000000 --- a/roc_fnb/website/templates/new_invite.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -
-{% block content %} - -
-- Tell the new user to visit - https://{{app_hostname}}/new-user. -
- -They will need this invite code:
- - - -
-