Compare commits
No commits in common. "dev" and "main" have entirely different histories.
|
|
@ -1,17 +0,0 @@
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
run-tests:
|
|
||||||
runs-on: docker
|
|
||||||
# container:
|
|
||||||
# image: python:alpine
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ forgejo.ref_head }}
|
|
||||||
sparse-checkout: |
|
|
||||||
roc_fnb
|
|
||||||
- run: |
|
|
||||||
python -m pip install .
|
|
||||||
python -m pytest --capture no
|
|
||||||
|
|
||||||
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,6 +3,3 @@
|
||||||
build/
|
build/
|
||||||
**/*.egg-info
|
**/*.egg-info
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
mounts/
|
|
||||||
**/*.pem
|
|
||||||
**/*.secret
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true
|
|
||||||
}
|
|
||||||
11
README.md
11
README.md
|
|
@ -1,11 +0,0 @@
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,19 +1,7 @@
|
||||||
services:
|
services:
|
||||||
fnb-website:
|
fnb-redirecter:
|
||||||
build: .
|
build: .
|
||||||
labels:
|
labels:
|
||||||
traefik.enable: true
|
|
||||||
traefik.http.routers.fnb-redirecter.rule: Host(`rocfnb.org`)
|
traefik.http.routers.fnb-redirecter.rule: Host(`rocfnb.org`)
|
||||||
traefik.http.routers.fnb-redirecter.tls.certresolver: letsencrypt_standalone
|
traefik.http.routers.fnb-redirecter.tls.certresolver: letsencrypt_standalone
|
||||||
networks: [ public, fnb-website ]
|
networks: [ public ]
|
||||||
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
|
|
||||||
1
fnb_redirecter/__init__.py
Normal file
1
fnb_redirecter/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from fnb_redirecter.server import app
|
||||||
3
fnb_redirecter/__main__.py
Normal file
3
fnb_redirecter/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from os import execlp
|
||||||
|
|
||||||
|
execlp('gunicorn', 'gunicorn', '--conf', '/app/wsgi-conf.py', '--bind', '0.0.0.0:1312', 'fnb_redirecter:app')
|
||||||
16
fnb_redirecter/server.py
Normal file
16
fnb_redirecter/server.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
@ -3,29 +3,14 @@ requires = ["setuptools", "setuptools-scm"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "roc_fnb_website"
|
name = "fnb_redirecter"
|
||||||
authors = [{ name = "D. Scott Boggs", email = "scott@techwork.zone" }]
|
authors = [{ name = "D. Scott Boggs", email = "scott@techwork.zone" }]
|
||||||
description = "Temporary placeholder for fnb web site"
|
description = "Temporary placeholder for fnb web site"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11" # Self type added
|
requires-python = ">=3.11" # Self type added
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
dependencies = [
|
dependencies = ["flask", "gunicorn"]
|
||||||
"flask",
|
|
||||||
"gunicorn",
|
|
||||||
"structlog",
|
|
||||||
"pymongo",
|
|
||||||
"scrypt",
|
|
||||||
"pyjwt",
|
|
||||||
|
|
||||||
# For tests
|
|
||||||
"pytest"
|
|
||||||
]
|
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|
||||||
[tool.setuptools]
|
[project.scripts]
|
||||||
# This is here because the top-level mounts directory gets interpreted by
|
# Put scripts here
|
||||||
# setuptools to be a module without it.
|
|
||||||
packages = ["roc_fnb"]
|
|
||||||
|
|
||||||
[tool.yapf]
|
|
||||||
based_on_style = "facebook"
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from os import execlp
|
|
||||||
|
|
||||||
execlp('gunicorn', 'gunicorn', '--conf', './wsgi-conf.py', '--bind', '0.0.0.0:1312', 'roc_fnb.website:app')
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -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({})
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
export APP_HOSTNAME=localhost:1312
|
|
||||||
python -m pytest --capture no --ignore mounts
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
from roc_fnb.util.logging import log
|
|
||||||
from roc_fnb.util.base64 import base64_decode, base64_encode
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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('=')
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from roc_fnb.website.server import app
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from roc_fnb.website.server.server import app
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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 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()
|
|
||||||
|
|
||||||
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_HOSTNAME = environ["APP_HOSTNAME"]
|
|
||||||
|
|
||||||
|
|
||||||
@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():
|
|
||||||
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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import json
|
|
||||||
|
|
||||||
from flask import request, redirect, render_template, g, abort, make_response, flash, jsonify, session
|
|
||||||
|
|
||||||
from roc_fnb.util import log, base64_encode, base64_decode
|
|
||||||
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')
|
|
||||||
@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 jsonify(status='OK')
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
@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')
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
<title>
|
|
||||||
Rochester Food Not Bombs
|
|
||||||
</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Teko:wght@300..700&display=swap" rel="stylesheet">
|
|
||||||
<script>
|
|
||||||
function start_animation() {
|
|
||||||
var element = document.getElementById("biglogo")
|
|
||||||
element.style.animation = 'none';
|
|
||||||
element.offsetHeight;
|
|
||||||
element.style.animation = null;
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>Blog not bombs ;)</h1>
|
|
||||||
<div class="flex-container">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
<title>
|
|
||||||
Rochester Food Not Bombs
|
|
||||||
</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Teko:wght@300..700&display=swap" rel="stylesheet">
|
|
||||||
<script>
|
|
||||||
function start_animation() {
|
|
||||||
var element = document.getElementById("biglogo")
|
|
||||||
element.style.animation = 'none';
|
|
||||||
element.offsetHeight;
|
|
||||||
element.style.animation = null;
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<img id="biglogo" class="spinny" onclick="start_animation()" src="logo.png" alt="logo">
|
|
||||||
<h1>Rochester Food Not Bombs!</h1>
|
|
||||||
Solidarity not charity: From Rochester, New York.
|
|
||||||
<p>Free hot meals are served every Saturday at 6:30PM in front of the <a
|
|
||||||
href="https://maps.app.goo.gl/gDNseCg1RQyeLKXF8">RTS Transit Center on St. Paul. </a></p>
|
|
||||||
<p>Help us cook every Saturday at 4PM at the <a href="http://thesquirrel.org/">Flying Squirrel Community Space.</a>
|
|
||||||
</p>
|
|
||||||
<div class="flex-container">
|
|
||||||
|
|
||||||
<div id="resources-box" class="fancy-border">
|
|
||||||
<h2>Quick resources</h2>
|
|
||||||
<ul id="resources-list">
|
|
||||||
<li><a href="https://docs.google.com/document/d/1UcoQ984Qwq22aR8YgyVk76VepxxFZM1QNllVeaHzWIs/edit?tab=t.0">List
|
|
||||||
of free food stands</a></li>
|
|
||||||
<li><a
|
|
||||||
href="https://www.google.com/maps/d/u/0/viewer?ll=43.19335689697014%2C-77.66649769973144&z=12&mid=1hGQ70VxoHncH6ardfHs6uh3HcrCp7iI">Map
|
|
||||||
of free food stands</a></li>
|
|
||||||
<li><a href="https://forms.gle/P3ZNLQe43yZS2MU99">Food Stand Repair and Info Form</a></li>
|
|
||||||
<li>Email: <a href="mailto:RocFoodNotBombs@proton.me">RocFoodNotBombs@proton.me</a></li>
|
|
||||||
<li>Instagram: <a href="https://www.instagram.com/rocfnb/">@rocfnb</a></li>
|
|
||||||
<li>Donate to our Venmo: @rocfoodnotbombs</li>
|
|
||||||
<li><a href="https://rocresources.anarchyplanet.org/">Other Resources in Rochester</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="blurb" class="fancy-border">
|
|
||||||
<p>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. </p>
|
|
||||||
<p>
|
|
||||||
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!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--<h2><a href="blog.html">See what we're up to :-)</a></h1>-->
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB |
|
|
@ -1,65 +0,0 @@
|
||||||
body {
|
|
||||||
background-color: #EEEEEE;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#resources-list {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.flex-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fancy-border {
|
|
||||||
border-width: 0.2em;
|
|
||||||
border-style: ridge;
|
|
||||||
border-radius: 1em;
|
|
||||||
}
|
|
||||||
#resources-box {
|
|
||||||
width: 20em;
|
|
||||||
border-color: #b9539f;
|
|
||||||
margin: auto;
|
|
||||||
margin-left: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
#blurb {
|
|
||||||
width: 40em;
|
|
||||||
border-color: #f27322;
|
|
||||||
margin: auto;
|
|
||||||
margin-top: 2rem;
|
|
||||||
margin-left: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
padding: 1em 2em 1em 2em;
|
|
||||||
}
|
|
||||||
:is(h1, h2, h3, h4, h5, h6) {
|
|
||||||
font-family: "Teko", serif;
|
|
||||||
font-optical-sizing: auto;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logospin {
|
|
||||||
from {
|
|
||||||
transform: rotate(-2turn);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinny {
|
|
||||||
animation-name: logospin;
|
|
||||||
animation-duration: 2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-container{
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin: 0.5rem;
|
|
||||||
border-color: black;
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
<title>
|
|
||||||
Rochester Food Not Bombs
|
|
||||||
</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Teko:wght@300..700&display=swap" rel="stylesheet">
|
|
||||||
<script>
|
|
||||||
function start_animation() {
|
|
||||||
var element = document.getElementById("biglogo")
|
|
||||||
element.style.animation = 'none';
|
|
||||||
element.offsetHeight;
|
|
||||||
element.style.animation = null;
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
{% extends "base.html" %} {% block content %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener("readystatechange", () => {
|
|
||||||
if (document.readyState === "complete") {
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const nameInput = document.getElementById("input-name");
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const passwordInput = document.getElementById("input-password");
|
|
||||||
/**
|
|
||||||
* @type {HTMLButtonElement}
|
|
||||||
*/
|
|
||||||
const button = document.getElementById("submit-button");
|
|
||||||
button.addEventListener("click", async (event) => {
|
|
||||||
const name = nameInput.value;
|
|
||||||
const password = passwordInput.value;
|
|
||||||
const result = await fetch("/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ name, password }),
|
|
||||||
headers: {'Content-Type': 'application/json'}
|
|
||||||
});
|
|
||||||
if (result.ok) {
|
|
||||||
window.location = '/me'
|
|
||||||
} else {
|
|
||||||
console.dir(result)
|
|
||||||
// TODO handle error!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<img
|
|
||||||
id="biglogo"
|
|
||||||
class="spinny"
|
|
||||||
onclick="start_animation()"
|
|
||||||
src="logo.png"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<h1>Rochester Food Not Bombs!</h1>
|
|
||||||
|
|
||||||
<h3>Login:</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="name">Your name</label>
|
|
||||||
<input type="text" id="input-name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password">Your password</label>
|
|
||||||
<input type="password" id="input-password" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button id="submit-button" type="submit">Log in</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
<div></div>
|
|
||||||
{% block content %}
|
|
||||||
<script>
|
|
||||||
let inviteCode
|
|
||||||
function inviteElements() {
|
|
||||||
return {
|
|
||||||
doneView: document.getElementById('done-view'),
|
|
||||||
inviteDisplay: document.getElementById('invite-code'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function copyInviteCodeToClipboard() {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(inviteCode)
|
|
||||||
.then(() => alert('invite code copied to clipboard'))
|
|
||||||
}
|
|
||||||
async function newInviteCode() {
|
|
||||||
const { doneView, inviteDisplay } = inviteElements()
|
|
||||||
const response = await fetch('/invite', {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const { invite } = await response.json()
|
|
||||||
inviteDisplay.innerText = inviteCode = invite
|
|
||||||
doneView.style.display = 'inherit'
|
|
||||||
} else {
|
|
||||||
console.dir('response')
|
|
||||||
alert('error creating new invite code')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function deleteInvite() {
|
|
||||||
const { doneView, inviteDisplay } = inviteElements()
|
|
||||||
const response = await fetch('/invite', {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: JSON.stringify({ invite_code: inviteCode }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
inviteCode = null
|
|
||||||
inviteDisplay.innerText = ""
|
|
||||||
doneView.display = 'none'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('readystatechange', () => {
|
|
||||||
if (document.readyState === 'complete') {
|
|
||||||
const doneView = inviteElements()
|
|
||||||
doneView.style.display = 'none'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
<img
|
|
||||||
id="biglogo"
|
|
||||||
class="spinny"
|
|
||||||
onclick="start_animation()"
|
|
||||||
src="logo.png"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<h1>Rochester Food Not Bombs!</h1>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label for="invite-description"
|
|
||||||
>Describe the invitation for future reference</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button>Click here to generate a new invite code</button>
|
|
||||||
</div>
|
|
||||||
<div id="done-view">
|
|
||||||
<p>
|
|
||||||
Tell the new user to visit
|
|
||||||
<a href="/new-user">https://{{app_hostname}}/new-user</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>They will need this invite code:</p>
|
|
||||||
<pre id="invite-code"></pre>
|
|
||||||
<button onclick="copyInviteCodeToClipboard()">Click here to copy it</button>
|
|
||||||
<button onclick="deleteInvite()">Delete invite</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<img id="biglogo" class="spinny" onclick="start_animation()" src="logo.png" alt="logo">
|
|
||||||
<h1>Rochester Food Not Bombs!</h1>
|
|
||||||
|
|
||||||
<p>This will be the profile/settings page for {{user.name}}</p>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
{% extends "base.html" %} {% block content %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener("readystatechange", () => {
|
|
||||||
if (document.readyState === "complete") {
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const nameInput = document.getElementById("input-name");
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const passwordInput = document.getElementById("input-password");
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const emailInput = document.getElementById("input-name");
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const pwConfInput = document.getElementById("input-password");
|
|
||||||
/**
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
const inviteCodeInput = document.getElementById("input-password");
|
|
||||||
/**
|
|
||||||
* @type {HTMLButtonElement}
|
|
||||||
*/
|
|
||||||
const button = document.getElementById("submit-button");
|
|
||||||
button.addEventListener("click", async (event) => {
|
|
||||||
const name = nameInput.value;
|
|
||||||
const password = passwordInput.value;
|
|
||||||
if (password !== pwConfInput.value) {
|
|
||||||
// TODO better handle errors
|
|
||||||
alert("passwords do not match")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const invite_code = inviteCodeInput.value
|
|
||||||
const email = emailInput.value
|
|
||||||
const result = await fetch("/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ name, password, invite_code, email }),
|
|
||||||
headers: {'Content-Type': 'application/json'}
|
|
||||||
});
|
|
||||||
if (result.ok) {
|
|
||||||
window.location = '/me'
|
|
||||||
} else {
|
|
||||||
console.dir(result)
|
|
||||||
// TODO handle error!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<img
|
|
||||||
id="biglogo"
|
|
||||||
class="spinny"
|
|
||||||
onclick="start_animation()"
|
|
||||||
src="logo.png"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<h1>Rochester Food Not Bombs!</h1>
|
|
||||||
|
|
||||||
<h3>Login:</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="name">Your name</label>
|
|
||||||
<input type="text" id="input-name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password">Your password</label>
|
|
||||||
<input type="password" id="input-password" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password">Confirm your password</label>
|
|
||||||
<input type="password" id="input-password" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="invite_code">Invitation Code</label>
|
|
||||||
<input type="password" id="input-invitation" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="email">Entering an email makes web site administration easier</label>
|
|
||||||
<input type="email" name="email" id="input-email" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button id="submit-button" type="submit">Log in</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
Loading…
Reference in a new issue