Compare commits

..

No commits in common. "submit-post" and "main" have entirely different histories.

34 changed files with 26 additions and 770 deletions

3
.gitignore vendored
View file

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

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:
fnb-website:
fnb-redirecter:
build: .
labels:
traefik.enable: true
traefik.http.routers.fnb-redirecter.rule: Host(`rocfnb.org`)
traefik.http.routers.fnb-redirecter.tls.certresolver: letsencrypt_standalone
networks: [ public, fnb-website ]
fnb-website-database:
image: mongodb/mongodb-community-server:8.0-ubi8
networks: [ fnb-website ]
volumes:
- type: bind
source: ./mounts/database
target: /data/db
networks:
fnb-website:
internal: true
networks: [ public ]

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"
[project]
name = "roc_fnb_website"
name = "fnb_redirecter"
authors = [{ name = "D. Scott Boggs", email = "scott@techwork.zone" }]
description = "Temporary placeholder for fnb web site"
readme = "README.md"
requires-python = ">=3.11" # Self type added
license = "AGPL-3.0-only"
dependencies = [
"flask",
"gunicorn",
"structlog",
"pymongo",
"scrypt",
"pyjwt",
# For tests
"pytest"
]
dependencies = ["flask", "gunicorn"]
dynamic = ["version"]
[tool.setuptools]
# This is here because the top-level mounts directory gets interpreted by
# setuptools to be a module without it.
packages = ["roc_fnb"]
[tool.yapf]
based_on_style = "facebook"
[project.scripts]
# Put scripts here

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,2 +0,0 @@
#!/usr/bin/env bash
python -m pytest --capture no --ignore mounts

View file

@ -1 +0,0 @@
from roc_fnb.util.logging import log

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,60 +0,0 @@
from os import environ
from typing import Optional, Self
from pdb import set_trace as debugger
from bson.objectid import ObjectId
import pymongo
from pymongo import MongoClient
from roc_fnb.website.models.user import User
KEYLEN = 64
with open('private-key.pem') as pk:
PRIVATE_KEY = pk.read()
with open('public-key.pem') as pk:
PUBLIK_KEY = pk.read()
class Database(MongoClient):
@classmethod
def from_env(cls) -> Self:
return cls(
host=environ.get('DATABASE_HOST', default='localhost'),
port=int(environ.get('DATABASE_PORT', default=27017))
)
@property
def db(self) -> pymongo.database.Database:
if (env := environ.get('ENV_MODE')) and env == 'production':
return self.production
else:
return self.development
def store_user(self, user: User) -> User:
"""Store the given user in the database, set _id on it, and return it"""
result = self.db.users.insert_one(user.document)
user._id = result.inserted_id
return user
def get_user_by_email(self, email: str) -> Optional[User]:
"""
Return the user associated with the given email.
This does not imply authentiation
"""
if result := self.db.users.find_one({'email': email}):
return User(**result)
return None
def get_user_by_name(self, name: str) -> Optional[User]:
if result := self.db.users.find_one({'name': name}):
return User(**result)
return None
def delete_user(self, _id: ObjectId):
self.db.users.delete_one({'_id': _id})
def get_user_by_id(self, id: ObjectId) -> Optional[User]:
if user := self.db.users.find_one({'_id': id}):
return User(**user)
return None

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,86 +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,46 +0,0 @@
from functools import wraps
import json
from pathlib import Path
from random import randbytes
from sys import stderr
from flask import Flask, redirect
from roc_fnb.util.env_file import env_file
from roc_fnb.util import log
from roc_fnb.website.database import Database
from roc_fnb.website.server.user import setup_user_routes
db = Database.from_env()
app = Flask(
import_name=__name__.split('.')[0],
static_url_path='/',
template_folder=Path(__file__).absolute().parent / 'templates',
static_folder=Path(__file__).absolute().parent / 'static',
)
app.secret_key = env_file('FLASK_SECRET', default_file='./flask.secret', default_fn=lambda: randbytes(12))
@app.before_request
def decode_user():
if user := session.get('user'):
g.user = JwtUser.from_json(data=json.loads(user))
@app.route('/ig')
def ig_redir():
return redirect('https://instagram.com/RocFNB')
@app.route('/donate')
def donate_redir():
return redirect('https://venmo.com/RocFoodNotBombs')
@app.route('/')
def index():
return redirect('/index.html')
setup_user_routes(app, db)

View file

@ -1,31 +0,0 @@
import json
from flask import request, redirect, render_template, g, abort
from roc_fnb.util import log
from roc_fnb.website.server.decorators import require_user, logger_request_bindings
def setup_user_routes(app, db):
@app.post('/login')
@logger_request_bindings(log)
def submit_login(log):
form = request.json
log.info('user attempting login', name=form.get('name'))
user = db.get_user_by_name(form['name'])
if not user.check_password(form['password']):
log.warn('incorrect password submitted', name=form['name'])
abort(401) # unauthorized
session['user'] = json.dumps(user.public_fields)
return redirect('/me')
@app.get('/login')
def render_login_page():
if getattr(g, 'user', None):
log.debug('user is already logged in', user=g.user)
return redirect('/me')
return render_template('login.html')
@app.get('/me')
@require_user()
def get_profile():
return render_template('profile.html', user=g.user)

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