Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

40 changed files with 26 additions and 1233 deletions

View file

@ -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
View file

@ -3,6 +3,3 @@
build/ build/
**/*.egg-info **/*.egg-info
**/__pycache__ **/__pycache__
mounts/
**/*.pem
**/*.secret

View file

@ -1,5 +0,0 @@
{
"arrowParens": "avoid",
"semi": false,
"singleQuote": true
}

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
from fnb_redirecter.server import app

View 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
View 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')

View file

@ -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"

View file

View file

@ -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')

View file

@ -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()

View file

@ -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({})

View file

@ -1,3 +0,0 @@
#!/usr/bin/env bash
export APP_HOSTNAME=localhost:1312
python -m pytest --capture no --ignore mounts

View file

@ -1,2 +0,0 @@
from roc_fnb.util.logging import log
from roc_fnb.util.base64 import base64_decode, base64_encode

View file

@ -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('=')

View file

@ -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')

View file

@ -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()

View file

@ -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

View file

@ -1 +0,0 @@
from roc_fnb.website.server import app

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -1 +0,0 @@
from roc_fnb.website.server.server import app

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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')

View file

@ -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>

View file

@ -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

View file

@ -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;
}

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}