initial commit

This commit is contained in:
FinnStutzenstein 2020-04-13 08:31:44 +02:00
commit 9d018e00be
82 changed files with 2407 additions and 0 deletions

114
.vscode/.ropeproject/config.py vendored Normal file
View File

@ -0,0 +1,114 @@
# The default ``config.py``
# flake8: noqa
def set_prefs(prefs):
"""This function is called before opening the project"""
# Specify which files and folders to ignore in the project.
# Changes to ignored resources are not added to the history and
# VCSs. Also they are not returned in `Project.get_files()`.
# Note that ``?`` and ``*`` match all characters but slashes.
# '*.pyc': matches 'test.pyc' and 'pkg/test.pyc'
# 'mod*.pyc': matches 'test/mod1.pyc' but not 'mod/1.pyc'
# '.svn': matches 'pkg/.svn' and all of its children
# 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o'
# 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o'
prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject',
'.hg', '.svn', '_svn', '.git', '.tox']
# Specifies which files should be considered python files. It is
# useful when you have scripts inside your project. Only files
# ending with ``.py`` are considered to be python files by
# default.
# prefs['python_files'] = ['*.py']
# Custom source folders: By default rope searches the project
# for finding source folders (folders that should be searched
# for finding modules). You can add paths to that list. Note
# that rope guesses project source folders correctly most of the
# time; use this if you have any problems.
# The folders should be relative to project root and use '/' for
# separating folders regardless of the platform rope is running on.
# 'src/my_source_folder' for instance.
# prefs.add('source_folders', 'src')
# You can extend python path for looking up modules
# prefs.add('python_path', '~/python/')
# Should rope save object information or not.
prefs['save_objectdb'] = True
prefs['compress_objectdb'] = False
# If `True`, rope analyzes each module when it is being saved.
prefs['automatic_soa'] = True
# The depth of calls to follow in static object analysis
prefs['soa_followed_calls'] = 0
# If `False` when running modules or unit tests "dynamic object
# analysis" is turned off. This makes them much faster.
prefs['perform_doa'] = True
# Rope can check the validity of its object DB when running.
prefs['validate_objectdb'] = True
# How many undos to hold?
prefs['max_history_items'] = 32
# Shows whether to save history across sessions.
prefs['save_history'] = True
prefs['compress_history'] = False
# Set the number spaces used for indenting. According to
# :PEP:`8`, it is best to use 4 spaces. Since most of rope's
# unit-tests use 4 spaces it is more reliable, too.
prefs['indent_size'] = 4
# Builtin and c-extension modules that are allowed to be imported
# and inspected by rope.
prefs['extension_modules'] = []
# Add all standard c-extensions to extension_modules list.
prefs['import_dynload_stdmods'] = True
# If `True` modules with syntax errors are considered to be empty.
# The default value is `False`; When `False` syntax errors raise
# `rope.base.exceptions.ModuleSyntaxError` exception.
prefs['ignore_syntax_errors'] = False
# If `True`, rope ignores unresolvable imports. Otherwise, they
# appear in the importing namespace.
prefs['ignore_bad_imports'] = False
# If `True`, rope will insert new module imports as
# `from <package> import <module>` by default.
prefs['prefer_module_from_imports'] = False
# If `True`, rope will transform a comma list of imports into
# multiple separate import statements when organizing
# imports.
prefs['split_imports'] = False
# If `True`, rope will remove all top-level import statements and
# reinsert them at the top of the module when making changes.
prefs['pull_imports_to_top'] = True
# If `True`, rope will sort imports alphabetically by module name instead
# of alphabetically by import statement, with from imports after normal
# imports.
prefs['sort_imports_alphabetically'] = False
# Location of implementation of
# rope.base.oi.type_hinting.interfaces.ITypeHintingFactory In general
# case, you don't have to change this value, unless you're an rope expert.
# Change this value to inject you own implementations of interfaces
# listed in module rope.base.oi.type_hinting.providers.interfaces
# For example, you can add you own providers for Django Models, or disable
# the search type-hinting in a class hierarchy, etc.
prefs['type_hinting_factory'] = (
'rope.base.oi.type_hinting.factory.default_type_hinting_factory')
def project_opened(project):
"""This function is called after opening the project"""
# Do whatever you like here!

BIN
.vscode/.ropeproject/objectdb vendored Normal file

Binary file not shown.

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.8.1
RUN apt-get -y update && apt-get -y upgrade && \
apt-get install --no-install-recommends -y wait-for-it postgresql-client
WORKDIR /app
COPY requirements.txt .
RUN pip install -U -r requirements.txt
COPY entrypoint.sh .
RUN mkdir log
ENV PYTHONPATH /app/
ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"]

20
Dockerfile.dev Normal file
View File

@ -0,0 +1,20 @@
FROM python:3.8.1
RUN apt-get -y update && apt-get -y upgrade && \
apt-get install --no-install-recommends -y wait-for-it postgresql-client
WORKDIR /app
COPY requirements.txt .
COPY requirements-dev.txt .
RUN pip install -U -r requirements.txt -r requirements-dev.txt
COPY entrypoint.sh .
COPY cleanup.sh .
COPY setup.cfg .
RUN mkdir log
ENV PYTHONPATH /app/
ENV FLASK_APP=max.app
ENV FLASK_ENV=development
ENTRYPOINT ["./entrypoint.sh"]
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "8000"]

7
LICENCE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2020 Finn Stutzenstein
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

21
Makefile Normal file
View File

@ -0,0 +1,21 @@
# DEVELOPMENT SERVER
build-dev:
docker build -t max-dev -f Dockerfile.dev .
run-dev: | build-dev
bash -c "trap 'docker-compose -f dc.dev.yml down' EXIT; MAX_COMMAND=\"flask run --host 0.0.0.0 --port 8000\" docker-compose -f dc.dev.yml up --build"
run-dev-interactive: | build-dev
MAX_COMMAND="sleep infinity" docker-compose -f dc.dev.yml up -d
docker-compose -f dc.dev.yml exec max "./entrypoint.sh"
docker-compose -f dc.dev.yml exec max bash || true
docker-compose -f dc.dev.yml down
run-cleanup: | build-dev
docker run -ti -v `pwd`/max:/app/max max-dev ./cleanup.sh
# PROD
build-prod:
docker build -t max .

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# MAX
A small little program to manage emails.

7
cleanup.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
printf "Black:\n"
black max/
printf "\nIsort:\n"
isort -rc max/
printf "\nFlake8:\n"
flake8 max/

25
csrf-test/index.html Normal file
View File

@ -0,0 +1,25 @@
<html>
<head>
<script
src="https://code.jquery.com/jquery-3.4.1.min.js"
integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
crossorigin="anonymous"></script>
</head>
<body>
<h1>Hi</h1>
<script>
function test() {
$.post("http://localhost:8000/user/1/toggle-enabled", null, function () {
console.log("success", arguments);
});
}
</script>
<button onclick="test()">Change</button>
<form action="http://localhost:8000/user/1/toggle-enabled" method="post">
<button type="submit">Submit</button>
</form>
</body>
</html>

28
dc.dev.yml Normal file
View File

@ -0,0 +1,28 @@
version: '3'
services:
max:
image: max-dev
volumes:
- ./max:/app/max
depends_on:
- postgresql
command: $MAX_COMMAND
ports:
- 8000:8000/tcp
environment:
- PGSQL_HOST=postgresql
- PGSQL_USER=max
- PGSQL_PASSWORD=max
- PGSQL_NAME=max
networks:
- postgresql
postgresql:
image: sameersbn/postgresql:10
environment:
- DB_USER=max
- DB_PASS=max
- DB_NAME=max
networks:
- postgresql
networks:
postgresql:

16
entrypoint.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
if [ -z "$PGSQL_HOST" ]
then
echo "Note: Postgresql not set!!"
else
export PGSQL_PORT="${PGSQL_PORT:-5432}"
wait-for-it --timeout=15 "$PGSQL_HOST:$PGSQL_PORT"
# Create schema in postgresql
export PGPASSWORD="$PGSQL_PASSWORD"
psql -1 -h "$PGSQL_HOST" -p "$PGSQL_PORT" -U "$PGSQL_USER" -d "$PGSQL_NAME" -f max/db/schema.sql
fi
exec "$@"

0
max/__init__.py Normal file
View File

83
max/app.py Normal file
View File

@ -0,0 +1,83 @@
import base64
import logging
import os
import sys
from logging.config import dictConfig
from flask import Flask
from werkzeug.routing import Rule as WerkzeugRule
from .auth import AuthMiddleware, init as init_auth
from .cli import register_cli
from .csrf import CsrfMiddleware
from .db import database
from .middlewares import LoggingMiddleware, add_middleware_stack
from .routes import init_routes
from .sessions import SessionInterface
class Rule(WerkzeugRule):
def __init__(self, *args, methods=None, **kwargs):
super().__init__(*args, **kwargs) # do not set methods
def init_logging(name):
dictConfig(
{
"version": 1,
"formatters": {
"default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"}
},
"handlers": {
"wsgi": {
"class": "logging.StreamHandler",
"stream": "ext://flask.logging.wsgi_errors_stream",
"formatter": "default",
},
"stdout": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "default",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "/app/log/max.log",
"formatter": "default",
"maxBytes": 1024 * 1024,
"backupCount": 5,
},
},
"root": {"level": "INFO", "handlers": ["stdout", "file"]},
"gunicorn": {"level": "INFO", "handlers": ["stdout", "file"]},
}
)
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
def main():
app = Flask(__name__)
init_logging(app.name)
app.logger.info("Hello there!")
app.logger.debug("If you see this, debug is activated!!")
app.url_rule_class = Rule
app.config.update(
SECRET_KEY="123456"
if app.debug
else base64.b64encode(os.urandom(64)).decode("utf-8"),
SESSION_COOKIE_SECURE=not app.debug,
SESSION_COOKIE_SAMESITE=False,
)
if not app.debug:
app.config.update(SESSION_COOKIE_DOMAIN="finn.st",)
add_middleware_stack(app, LoggingMiddleware, AuthMiddleware, CsrfMiddleware)
database.init(app)
app.session_interface = SessionInterface()
register_cli(app)
init_auth(app)
init_routes(app)
return app
app = main()

7
max/auth/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from .auth import change_password, check_password, hash_password # noqa
from .middlewares import AuthMiddleware, no_authentication_required # noqa
from .routes import init_routes
def init(app):
init_routes(app)

60
max/auth/auth.py Normal file
View File

@ -0,0 +1,60 @@
import base64
import hashlib
import os
from flask import session
from max.crypto import constant_time_compare, force_bytes
from max.db import get_user_by_email, update_user
SSHA512_PREFIX = "{SSHA512}"
def login(email, password):
user = get_user_by_email(email)
if user is None:
return False
if not check_password(password, user.passwordhash):
return False
session["user_id"] = user.id
session["user_email"] = user.email
return True
def logout():
session.clear()
def change_password(user, password):
hash = hash_password(password)
user.passwordhash = hash
update_user(user)
session.set_new_id()
def hash_password(password: str, salt: bytes = None):
if salt is None:
salt = os.urandom(32)
sha = hashlib.sha512()
sha.update(force_bytes(password))
sha.update(salt)
sha_and_salt = base64.b64encode(sha.digest() + salt)
return SSHA512_PREFIX + sha_and_salt.decode()
def get_salt(hash: str):
if not hash.startswith(SSHA512_PREFIX):
raise RuntimeError(f"The hash does not start with '{SSHA512_PREFIX}''")
sha_and_salt = base64.b64decode(hash[len(SSHA512_PREFIX) :]) # noqa
return sha_and_salt[64:]
def check_password(password: str, hash: str):
salt = get_salt(hash)
hash_from_password = hash_password(password, salt=salt)
return constant_time_compare(hash, hash_from_password)

35
max/auth/middlewares.py Normal file
View File

@ -0,0 +1,35 @@
from functools import wraps
from flask import session, url_for
from max.csrf import CSRF_TOKEN_LENGTH
from max.middlewares import BaseMiddleware
from max.views import redirect
class AuthMiddleware(BaseMiddleware):
def __call__(self, *args, **kwargs):
if self.app.debug and False:
session["user_id"] = 1
session["user_email"] = "a@finn.st"
session["csrf_token"] = "a" * CSRF_TOKEN_LENGTH
else:
if not getattr(self.handler, "no_auth", False):
user_id = session.get("user_id")
if not user_id:
# No usage of flash here to not create a session...
return redirect(url_for("login", a=1))
return self.handler(*args, **kwargs)
def no_authentication_required(handler):
# We could just do handler.no_auth=True, but decorators
# are nicer if they don't have side-effects, so we return a new
# function.
@wraps(handler)
def wrapped_handler(*args, **kwargs):
return handler(*args, **kwargs)
wrapped_handler.no_auth = True
return wrapped_handler

50
max/auth/routes.py Normal file
View File

@ -0,0 +1,50 @@
from flask import flash, session, url_for
from max.csrf import no_csrf
from max.views import BaseTemplateGetView, BaseView
from .auth import login, logout
from .middlewares import no_authentication_required
def init_routes(app):
app.add_url_rule(
"/login", "login", no_csrf(no_authentication_required(Login.as_view()))
)
app.add_url_rule("/logout", "logout", Logout.as_view())
class Login(BaseTemplateGetView):
template_name = "auth/login.html"
webpagetitle = "Login"
def get(self):
if session.get("user_id") is not None:
return self.redirect("/")
return super().get()
def get_context(self):
context = super().get_context()
context["alert"] = bool(self.request.args.get("a", False))
return context
def post(self):
email = self.request.form.get("email")
password = self.request.form.get("password")
if login(email, password):
flash("Login was sucessfull!", category="success")
return self.redirect("/")
else:
self.app.logger.info(
"Failed login attempt from %s", self.request.real_remote_addr
)
flash("Email or password was wrong!", category="error")
self.context["email"] = email
class Logout(BaseView):
def post(self):
logout()
flash("Logout was sucessfull!", category="success")
return self.redirect(url_for("login"))

31
max/cli.py Normal file
View File

@ -0,0 +1,31 @@
import click
from .auth import check_password as auth_check_password, hash_password
from .db import reset_or_create_user
def register_cli(app):
app.cli.command("reset-admin")(reset_admin)
app.cli.command("make-hash")(make_hash)
app.cli.command("check-password")(check_password)
@click.argument("email")
@click.argument("password")
def reset_admin(email, password):
""" Resets or create a user given by the email """
hash = hash_password(password)
reset_or_create_user(email, hash)
@click.argument("password")
def make_hash(password):
""" Creates a salted hash from the password """
print(hash_password(password))
@click.argument("password")
@click.argument("hash")
def check_password(password, hash):
""" Checks if the hash is valid for the password """
print(auth_check_password(password, hash))

20
max/crypto.py Normal file
View File

@ -0,0 +1,20 @@
import secrets
def force_bytes(s):
if isinstance(s, bytes):
return s
if isinstance(s, memoryview):
return bytes(s)
return str(s).encode("utf-8", "strict")
def constant_time_compare(val1, val2):
return secrets.compare_digest(force_bytes(val1), force_bytes(val2))
def get_random_string(
length,
allowed_chars=("abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
):
return "".join(secrets.choice(allowed_chars) for i in range(length))

58
max/csrf.py Normal file
View File

@ -0,0 +1,58 @@
from functools import wraps
from flask import request, session
from max.crypto import constant_time_compare, get_random_string
from max.middlewares import BaseMiddleware
CSRF_TOKEN_KEY = "csrf_token"
CSRF_TOKEN_LENGTH = 32
class CsrfMiddleware(BaseMiddleware):
def __call__(self, *args, **kwargs):
error = self.process_request()
if error is not None:
# clear csrf from session
if CSRF_TOKEN_KEY in session:
del session[CSRF_TOKEN_KEY]
return error, 400
return self.handler(*args, **kwargs)
def process_request(self):
""" If a string is returned, it mist be used as a 400er error """
if getattr(self.handler, "no_csrf", False):
return
session_has_csrf_token = CSRF_TOKEN_KEY in session
method_is_unsafe = request.method.lower() != "get"
if not session_has_csrf_token and method_is_unsafe:
return "CSRF token is missing"
elif not session_has_csrf_token and not method_is_unsafe:
session[CSRF_TOKEN_KEY] = get_random_string(CSRF_TOKEN_LENGTH)
elif session_has_csrf_token and method_is_unsafe:
return self.check_token(session[CSRF_TOKEN_KEY], request.form)
def check_token(self, session_token, formdata):
if CSRF_TOKEN_KEY not in formdata:
return "No CSRF token"
request_token = formdata[CSRF_TOKEN_KEY]
if len(request_token) != CSRF_TOKEN_LENGTH:
return "Wrong CSRF token"
elif not constant_time_compare(request_token, session_token):
return "Wrong CSRF token"
def no_csrf(handler):
@wraps(handler)
def wrapped_handler(*args, **kwargs):
return handler(*args, **kwargs)
wrapped_handler.no_csrf = True
return wrapped_handler

19
max/db/__init__.py Normal file
View File

@ -0,0 +1,19 @@
from .aliases import ( # noqa
create_alias,
delete_alias,
get_alias_with_user_by_id,
get_aliases,
update_alias,
)
from .common import EmailAlreadyExists # noqa
from .connection import database # noqa
from .users import ( # noqa
create_user,
delete_user,
get_user,
get_user_by_email,
get_user_by_user_id,
get_users,
reset_or_create_user,
update_user,
)

72
max/db/aliases.py Normal file
View File

@ -0,0 +1,72 @@
from dataclasses import dataclass
from .common import create_email
from .connection import with_cursor
from .users import User
@dataclass
class Alias:
id: int
source_email_id: int
email: str
destination_user_id: int
enabled: bool
note: str
@with_cursor
def get_aliases(cur, user_id):
cur.execute(
"""\
SELECT a.id, a.source_email_id, e.email, a.destination_user_id, a.enabled, a.note
FROM aliases a INNER JOIN emails e ON a.source_email_id=e.id
WHERE a.destination_user_id=%s""",
[user_id],
)
return [Alias(*row) for row in cur.fetchall()]
@with_cursor
def get_alias_with_user_by_id(cur, alias_id):
cur.execute(
"""\
SELECT a.id, a.source_email_id, ea.email, a.destination_user_id, a.enabled, a.note,
u.id, u.email_id, eu.email, u.passwordhash, u.enabled, u.note
FROM aliases a
INNER JOIN emails ea ON a.source_email_id=ea.id
INNER JOIN users u ON a.destination_user_id=u.id
INNER JOIN emails eu ON u.email_id=eu.id
WHERE a.id=%s""",
[alias_id],
)
row = cur.fetchone()
if not row:
return None
return Alias(*row[:6]), User(*row[6:])
@with_cursor
def create_alias(cur, email, user, enabled, note):
email_id = create_email(cur, email)
cur.execute(
"""
INSERT INTO aliases (source_email_id, destination_user_id, enabled, note)
VALUES (%s, %s, %s, %s)""",
[email_id, user.id, enabled, note],
)
@with_cursor
def update_alias(cur, alias):
""" updates enabled and note """
cur.execute(
"UPDATE aliases SET enabled=%s, note=%s WHERE id=%s",
[alias.enabled, alias.note, alias.id],
)
@with_cursor
def delete_alias(cur, alias):
cur.execute("DELETE FROM aliases WHERE id=%s", [alias.id])
cur.execute("DELETE FROM emails WHERE id=%s", [alias.source_email_id])

33
max/db/common.py Normal file
View File

@ -0,0 +1,33 @@
class EmailAlreadyExists(Exception):
def __init__(self, msg):
self.msg = msg
def create_email(cur, email):
cur.execute("SELECT EXISTS (SELECT 1 FROM emails WHERE email=%s)", [email])
email_exists = cur.fetchone()[0]
if email_exists:
cur.execute("SELECT id FROM emails WHERE email=%s", [email])
email_id = cur.fetchone()[0]
# Get user?
cur.execute(
"""
SELECT e.email FROM aliases a
INNER JOIN users u ON a.destination_user_id=u.id
INNER JOIN emails e ON u.email_id=e.id
WHERE a.source_email_id=%s""",
[email_id],
)
alias_user_email = cur.fetchone()
if alias_user_email:
msg = f"Used as an alias by {alias_user_email[0]}."
else:
msg = "Used as an user."
raise EmailAlreadyExists(msg)
else:
cur.execute("INSERT INTO emails (email) VALUES (%s) RETURNING id", [email])
email_id = cur.fetchone()[0]
return email_id

107
max/db/connection.py Normal file
View File

@ -0,0 +1,107 @@
import os
from functools import wraps
import psycopg2
class DatabaseError(Exception):
pass
class ProxyCursor(object):
def __init__(self, cur):
self.cur = cur
original_execute = object.__getattribute__(self, "cur").execute
@wraps(original_execute)
def execute(query, args=None):
database.add_query(query, args)
return original_execute(query, args)
self.execute = execute
def __getattribute__(self, name):
__dict__ = object.__getattribute__(self, "__dict__")
if name in __dict__ and name != "cur":
return object.__getattribute__(self, name)
else:
return getattr(object.__getattribute__(self, "cur"), name)
def get_env(name, default=None):
return os.environ.get(name, default)
def ensure_connection(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
while True:
try:
result = fn(*args, **kwargs)
except psycopg2.InterfaceError:
continue
return result
return wrapper
def with_cursor(fn):
@wraps(fn)
@ensure_connection
def wrapper(*args, **kwargs):
conn = database.get_connection()
with conn:
with conn.cursor() as cur:
cur = ProxyCursor(cur)
return fn(cur, *args, **kwargs)
return wrapper
class Database:
app = None
def __init__(self):
self.connection = None
self.query_count = 0
def init(self, app):
self.app = app
def get_connection(self):
if not self.app:
raise RuntimeError()
if not self.connection:
self.connection = self.create_connection()
return self.connection
def create_connection(self):
try:
return psycopg2.connect(
host=get_env("PGSQL_HOST"),
port=int(get_env("PGSQL_PORT", 5432)),
database=get_env("PGSQL_NAME"),
user=get_env("PGSQL_USER"),
password=get_env("PGSQL_PASSWORD"),
)
except psycopg2.Error as e:
raise DatabaseError(f"Database connect error {e.pgcode}: {e.pgerror}")
def add_query(self, query, args=None):
if args is None:
args = []
self.app.logger.debug('query="%s", args="%s"', query, str(args))
self.query_count += 1
def collect_query_count(self):
val = self.query_count
self.query_count = 0
return val
def shutdown(self):
if self.connection:
self.connection.close()
database = Database()

38
max/db/schema.sql Normal file
View File

@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS domains (
name varchar PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS emails (
id serial PRIMARY KEY,
email varchar(255) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS users (
id serial PRIMARY KEY,
email_id integer REFERENCES emails(id) ON DELETE RESTRICT NOT NULL UNIQUE,
passwordhash varchar(255) NOT NULL,
enabled boolean NOT NULL DEFAULT TRUE,
note text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS aliases (
id serial PRIMARY KEY,
source_email_id integer REFERENCES emails(id) ON DELETE RESTRICT NOT NULL,
destination_user_id integer REFERENCES users(id) ON DELETE RESTRICT NOT NULL,
enabled boolean NOT NULL DEFAULT TRUE,
note text,
UNIQUE(source_email_id, destination_user_id)
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM domains) THEN
INSERT INTO domains (name) VALUES ('finn.st');
END IF;
END$$;
--INSERT INTO users (email, passwordhash) VALUES ('me@finn.st', '{SSHA512}dhnq/FPGDsaWp4FL+MKR8IlDetl9G7qeNTImbotgNkKcQ5rIcew1fRb50xWLt0JRJs9wrq3sP3Wcir2uqw/GZX8no1bkFmjL5Z/RVKGkhxI=');
--INSERT INTO aliases (source, destination_id) VALUES ('alias@finn.st', 1), ('postmaster@finn.st', 1);
INSERT INTO emails (email) VALUES ('a@finn.st');
INSERT INTO users (email_id, passwordhash) VALUES (1, '{SSHA512}kYrSSW/1sHuJg8j4o3us13z4MyTR8RkOQ8UkywpgeoB0VuP2klLAX/junFRSxzUZyfthAJRWqfpSUS4aqFK+7QeZTu73JasrHmIafCIiWc0=');

110
max/db/users.py Normal file
View File

@ -0,0 +1,110 @@
from dataclasses import dataclass
from .common import create_email
from .connection import with_cursor
@dataclass
class User:
id: int
email_id: int
email: str
passwordhash: str
enabled: bool
note: str
@with_cursor
def get_user_by_email(cur, email):
return get_user(
cur,
"""\
SELECT u.id, u.email_id, e.email, u.passwordhash, u.enabled, u.note
FROM users u INNER JOIN emails e ON u.email_id=e.id
WHERE e.email=%s""",
email,
)
@with_cursor
def get_user_by_user_id(cur, user_id):
return get_user(
cur,
"""\
SELECT u.id, u.email_id, e.email, u.passwordhash, u.enabled, u.note
FROM users u INNER JOIN emails e ON u.email_id=e.id
WHERE u.id=%s""",
user_id,
)
def get_user(cur, query, param):
cur.execute(query, [param])
row = cur.fetchone()
if not row:
return None
return User(*row)
@with_cursor
def get_users(cur):
cur.execute(
"""\
SELECT u.id, u.email_id, e.email, u.passwordhash, u.enabled, u.note
FROM users u INNER JOIN emails e ON u.email_id=e.id"""
)
return [User(*row) for row in cur.fetchall()]
@with_cursor
def update_user(cur, user):
""" only pwhash, enabled, note """
cur.execute(
"UPDATE users SET passwordhash=%s, enabled=%s, note=%s WHERE id=%s",
[user.passwordhash, user.enabled, user.note, user.id],
)
@with_cursor
def create_user(cur, email, passwordhash, enabled, note):
email_id = create_email(cur, email)
cur.execute(
"""\
INSERT INTO users (email_id, passwordhash, enabled, note)
VALUES (%s, %s, %s, %s)""",
[email_id, passwordhash, enabled, note],
)
@with_cursor
def delete_user(cur, user):
cur.execute("DELETE FROM users WHERE id=%s", [user.id])
cur.execute("DELETE FROM emails WHERE id=%s", [user.email_id])
@with_cursor
def reset_or_create_user(cur, email, hash):
# does the email exists?
cur.execute("SELECT id FROM emails WHERE email=%s", [email])
email_id = cur.fetchone()
if email_id is not None:
email_id = email_id[0]
# alias or user?
cur.execute("SELECT id FROM users WHERE email_id=%s", [email_id])
user_id = cur.fetchone()
if user_id is not None:
user_id = user_id[0]
# update User
cur.execute("UPDATE users SET passwordhash=%s WHERE id=%s", [hash, user_id])
print(f"reset password for {email}")
else:
print(f"{email} is used as an alias. Please specify another one.")
else:
# user des not exists. create him.
cur.execute("INSERT INTO emails (email) VALUES (%s) RETURNING id", [email])
email_id = cur.fetchone()[0]
cur.execute(
"INSERT INTO users (email_id, passwordhash, enabled, note) VALUES (%s, %s, %s, %s)",
[email_id, hash, False, "Created by commandline"],
)
print(f"Created {email}")

2
max/exceptions.py Normal file
View File

@ -0,0 +1,2 @@
class ImproperlyConfigured(Exception):
pass

70
max/middlewares.py Normal file
View File

@ -0,0 +1,70 @@
import time
from flask import Response, request, session
from max.db import database
class BaseMiddleware:
def __init__(self, app, handler):
self.app = app
self.handler = handler
for attr in ("no_auth", "no_csrf"):
if hasattr(handler, attr):
setattr(self, attr, True)
def __call__(self, *args, **kwargs):
raise NotImplementedError()
class LoggingMiddleware(BaseMiddleware):
def __call__(self, *args, **kwargs):
real_ip = request.headers.get("X-RealIp", None)
if real_ip is None and self.app.debug:
real_ip = request.remote_addr
setattr(request, "real_remote_addr", real_ip)
start = time.time()
result = self.handler(*args, **kwargs)
response: Response = self.app.make_response(result)
end = time.time()
time_in_ms = f"{(end-start)*1000:.2f}"
self.app.logger.info(
'%s %s %s user_agent="%s" referrer="%s" db_queries=%s %s '
+ "%s user_id=%s session=%s %sms",
request.method,
request.url,
request.real_remote_addr,
request.user_agent,
request.referrer,
database.collect_query_count(),
response.status_code,
response.content_length,
session.get("user_id"),
session.id,
time_in_ms,
)
return result
def add_middleware_stack(app, *middlewares):
original_add_url_rule = app.add_url_rule
def url_rule_with_middlewares(
rule, endpoint=None, view_func=None, provide_automatic_options=None, **options
):
if view_func:
for middleware in middlewares[::-1]:
view_func = middleware(app, view_func)
return original_add_url_rule(
rule,
endpoint=endpoint,
view_func=view_func,
provide_automatic_options=provide_automatic_options,
**options,
)
app.add_url_rule = url_rule_with_middlewares

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

63
max/routes/__init__.py Normal file
View File

@ -0,0 +1,63 @@
from max.auth import no_authentication_required
from max.csrf import no_csrf
from .alias.create import AliasCreate
from .alias.delete import AliasDelete
from .alias.detail import AliasDetail
from .alias.edit_note import AliasEditNote
from .alias.toggle_enabled import AliasToggleEnabled
from .favicon import Favicon
from .robots import RobotsTXT
from .user.change_password import UserChangePassword
from .user.create import UserCreate
from .user.delete import UserDelete
from .user.detail import UserDetail
from .user.edit_note import UserEditNote
from .user.list import UserList
from .user.toggle_enabled import UserToggleEnabled
def init_routes(app):
app.add_url_rule("/", "user-list", UserList.as_view())
app.add_url_rule("/user/create", "user-create", UserCreate.as_view())
app.add_url_rule(
"/user/<int:user_id>/change-password",
"user-change-password",
UserChangePassword.as_view(),
)
app.add_url_rule(
"/user/<int:user_id>/edit-note", "user-edit-note", UserEditNote.as_view()
)
app.add_url_rule(
"/user/<int:user_id>/toggle-enabled",
"user-toggle-enabled",
UserToggleEnabled.as_view(),
)
app.add_url_rule("/user/<int:user_id>/delete", "user-delete", UserDelete.as_view())
app.add_url_rule("/user/<int:user_id>", "user-detail", UserDetail.as_view())
app.add_url_rule(
"/user/<int:user_id>/aliases/create", "alias-create", AliasCreate.as_view()
)
app.add_url_rule("/alias/<int:alias_id>", "alias-detail", AliasDetail.as_view())
app.add_url_rule(
"/alias/<int:alias_id>/delete", "alias-delete", AliasDelete.as_view()
)
app.add_url_rule(
"/alias/<int:alias_id>/edit-note", "alias-edit-note", AliasEditNote.as_view()
)
app.add_url_rule(
"/alias/<int:alias_id>/toggle-enabled",
"alias-toggle-enabled",
AliasToggleEnabled.as_view(),
)
app.add_url_rule(
"/robots.txt",
"robots",
no_csrf(no_authentication_required(RobotsTXT.as_view())),
)
app.add_url_rule(
"/favicon.png",
"favicon",
no_csrf(no_authentication_required(Favicon.as_view())),
)

View File

@ -0,0 +1,57 @@
from flask import flash
from max.db import EmailAlreadyExists, create_alias
from ..base_alias_views import BackToAliasesMixin
from ..base_user_views import FetchUserTemplateGetView
class AliasCreate(BackToAliasesMixin, FetchUserTemplateGetView):
template_name = "alias/create.html"
def get_webpagetitle(self):
return f"Create new alias for {self.user.email}"
def post(self):
error = False
email = self.request.form.get("email")
if not isinstance(email, str) or not email:
flash("Email must be given", category="error")
error = True
else:
self.email = email
enabled = self.request.form.get("enabled")
if enabled == "on":
self.enabled = True
elif enabled is None:
self.enabled = False
else:
flash("Enabled is not set", category="error")
error = True
note = self.request.form.get("note", "")
if not isinstance(note, str):
flash("Not emust be a string", category="error")
error = True
else:
self.note = note
if error:
return # force re-rendering
try:
create_alias(self.email, self.user, self.enabled, self.note)
except EmailAlreadyExists as e:
flash(f"Email already exists: {e.msg}", category="error")
return
flash(f"Creation of alias {email} was successful!", category="success")
return self.redirect()
def get_context(self):
context = super().get_context()
for key in ("email", "enabled", "note"):
if hasattr(self, key):
context[key] = getattr(self, key)
return context

View File

@ -0,0 +1,14 @@
from flask import flash, url_for
from max.db import delete_alias
from ..base_alias_views import BackToAliasesMixin, FetchAliasTemplateGetView
class AliasDelete(BackToAliasesMixin, FetchAliasTemplateGetView):
template_name = "alias/delete.html"
def post(self):
delete_alias(self.alias)
flash(f"{self.alias.email} was deleted successfully", category="success")
return self.redirect(url_for("user-detail", user_id=self.user.id))

View File

@ -0,0 +1,8 @@
from ..base_alias_views import BackToAliasesMixin, FetchAliasTemplateGetView
class AliasDetail(BackToAliasesMixin, FetchAliasTemplateGetView):
template_name = "alias/detail.html"
def get_webpagetitle(self):
return self.alias.email

View File

@ -0,0 +1,24 @@
from flask import flash
from max.db import update_alias
from ..base_alias_views import BackToAliasesMixin, FetchAliasTemplateGetView
class AliasEditNote(BackToAliasesMixin, FetchAliasTemplateGetView):
template_name = "alias/edit-note.html"
def get_webpagetitle(self):
return "Edit note for " + self.alias.email
def post(self):
note = self.request.form.get("note", "")
if not isinstance(note, str):
flash("invalid data", category="error")
return
self.alias.note = note
update_alias(self.alias)
flash("Note changed successfully!", category="success")
return self.redirect()

View File

@ -0,0 +1,12 @@
from max.db import update_alias
from max.views import BaseView
from ..base_alias_views import BackToAliasesMixin, FetchAliasMixin
class AliasToggleEnabled(FetchAliasMixin, BackToAliasesMixin, BaseView):
def post(self):
self.alias.enabled = not self.alias.enabled
update_alias(self.alias)
_, back_url = self.get_back_text_and_url()
return self.redirect(back_url)

View File

@ -0,0 +1,31 @@
from flask import flash, url_for
from max.db import get_alias_with_user_by_id
from max.views import BaseTemplateGetView
class FetchAliasMixin:
def before_handler(self):
result = get_alias_with_user_by_id(self.alias_id)
if result is None:
flash(
f"The alias with id {self.alias_id} does not exist.", category="error"
)
return self.redirect()
self.alias, self.user = result
class FetchAliasTemplateGetView(FetchAliasMixin, BaseTemplateGetView):
def get_context(self):
context = super().get_context()
context["alias"] = self.alias
context["user"] = self.user
return context
class BackToAliasesMixin:
def get_back_text_and_url(self):
if self.request.args.get("return") == "detail":
return self.user.email, url_for("alias-detail", alias_id=self.alias.id)
else:
return self.user.email, url_for("user-detail", user_id=self.user.id)

View File

@ -0,0 +1,27 @@
from flask import flash, url_for
from max.db import get_user_by_user_id
from max.views import BaseTemplateGetView
class FetchUserMixin:
def before_handler(self):
self.user = get_user_by_user_id(self.user_id)
if self.user is None:
flash(f"The user with id {self.user_id} does not exist.", category="error")
return self.redirect()
class FetchUserTemplateGetView(FetchUserMixin, BaseTemplateGetView):
def get_context(self):
context = super().get_context()
context["user"] = self.user
return context
class BackToUsersMixin:
def get_back_text_and_url(self):
if self.request.args.get("return") == "detail":
return self.user.email, url_for("user-detail", user_id=self.user.id)
else:
return "User list", url_for("user-list")

28
max/routes/favicon.py Normal file
View File

@ -0,0 +1,28 @@
import os
import random
from flask import current_app, send_from_directory
from max.views import BaseView
FAVICON_FOLDER = "private_static/favicons"
STATE_FAVICON_ATTR = "available_favicons"
class Favicon(BaseView):
_state = {}
def get(self):
random_favicon = self.get_random_favicon_filename()
return send_from_directory(FAVICON_FOLDER, random_favicon, add_etags=False)
def ensure_favicons(self):
if STATE_FAVICON_ATTR not in self._state:
directory = os.fspath(FAVICON_FOLDER)
directory = os.path.join(current_app.root_path, directory)
self._state[STATE_FAVICON_ATTR] = [a for a in os.listdir(directory)]
def get_random_favicon_filename(self):
self.ensure_favicons()
return random.choice(self._state[STATE_FAVICON_ATTR])

8
max/routes/robots.py Normal file
View File

@ -0,0 +1,8 @@
from flask import send_from_directory
from max.views import BaseView
class RobotsTXT(BaseView):
def get(self):
return send_from_directory("private_static", "robots.txt")

View File

@ -0,0 +1,31 @@
from flask import flash, session
from max.auth import change_password
from max.sessions import session_storage
from ..base_user_views import BackToUsersMixin, FetchUserTemplateGetView
class UserChangePassword(BackToUsersMixin, FetchUserTemplateGetView):
template_name = "user/change-password.html"
def get_webpagetitle(self):
return "Change password for " + self.user.email
def post(self):
pw1 = self.request.form.get("password1")
pw2 = self.request.form.get("password2")
if not pw1 or not pw2:
flash("Both passwords must be given", category="error")
return
if not isinstance(pw1, str) or not isinstance(pw2, str):
flash("Invalid data")
return
if pw1 != pw2:
flash("Both password must be the same", category="error")
return
change_password(self.user, pw1)
session_storage.remove_user_sessions(self.user.id, except_id=session["user_id"])
flash("Password changed successfully!", category="success")
return self.redirect()

71
max/routes/user/create.py Normal file
View File

@ -0,0 +1,71 @@
from flask import flash
from max.auth import hash_password
from max.db import EmailAlreadyExists, create_user
from max.views import BaseTemplateGetView
from ..base_user_views import BackToUsersMixin
class UserCreate(BackToUsersMixin, BaseTemplateGetView):
template_name = "user/create.html"
webpagetitle = "Create new user"
def post(self):
error = False
email = self.request.form.get("email")
if not isinstance(email, str) or not email:
flash("Email must be given", category="error")
error = True
else:
self.email = email
pw1 = self.request.form.get("password1")
pw2 = self.request.form.get("password2")
if not pw1 or not pw2:
flash("Both password must be given", category="error")
error = True
elif not isinstance(pw1, str) or not isinstance(pw2, str):
flash("Invalid data")
error = True
elif pw1 != pw2:
flash("Both password must be the same", category="error")
error = True
else:
self.password = pw1
enabled = self.request.form.get("enabled")
if enabled == "on":
self.enabled = True
elif enabled is None:
self.enabled = False
else:
flash("Enabled is not set", category="error")
error = True
note = self.request.form.get("note", "")
if not isinstance(note, str):
flash("Not emust be a string", category="error")
error = True
else:
self.note = note
if error:
return # force re-rendering
passwordhash = hash_password(self.password)
try:
create_user(self.email, passwordhash, self.enabled, self.note)
except EmailAlreadyExists as e:
flash(f"Email already exists: {e.msg}", category="error")
return
flash(f"Creation of {email} was successful!", category="success")
return self.redirect()
def get_context(self):
context = super().get_context()
for key in ("email", "password", "enabled", "note"):
if hasattr(self, key):
context[key] = getattr(self, key)
return context

23
max/routes/user/delete.py Normal file
View File

@ -0,0 +1,23 @@
from flask import flash, session, url_for
from max.db import delete_user
from max.sessions import session_storage
from ..base_user_views import BackToUsersMixin, FetchUserTemplateGetView
class UserDelete(BackToUsersMixin, FetchUserTemplateGetView):
template_name = "user/delete.html"
def get_webpagetitle(self):
return f"Delete {self.user.email}"
def post(self):
if self.user.id == session["user_id"]:
flash("You cannot delete yourself", category="error")
return self.redirect()
delete_user(self.user)
session_storage.remove_user_sessions(self.user.id)
flash(f"{self.user.email} was deleted successfully", category="success")
return self.redirect(url_for("user-list"))

20
max/routes/user/detail.py Normal file
View File

@ -0,0 +1,20 @@
from flask import url_for
from max.db import get_aliases
from ..base_user_views import FetchUserTemplateGetView
class UserDetail(FetchUserTemplateGetView):
template_name = "user/detail.html"
def get_webpagetitle(self):
return self.user.email
def get_back_text_and_url(self):
return "User list", url_for("user-list")
def get_context(self):
context = super().get_context()
context["aliases"] = get_aliases(self.user_id)
return context

View File

@ -0,0 +1,24 @@
from flask import flash
from max.db import update_user
from ..base_user_views import BackToUsersMixin, FetchUserTemplateGetView
class UserEditNote(BackToUsersMixin, FetchUserTemplateGetView):
template_name = "user/edit-note.html"
def get_webpagetitle(self):
return "Edit note for " + self.user.email
def post(self):
note = self.request.form.get("note", "")
if not isinstance(note, str):
flash("invalid data", category="error")
return
self.user.note = note
update_user(self.user)
flash("Note changed successfully!", category="success")
return self.redirect()

12
max/routes/user/list.py Normal file
View File

@ -0,0 +1,12 @@
from max.db import get_users
from max.views import BaseTemplateGetView
class UserList(BaseTemplateGetView):
template_name = "user/list.html"
webpagetitle = "Main"
def get_context(self):
context = super().get_context()
context["users"] = get_users()
return context

View File

@ -0,0 +1,12 @@
from max.db import update_user
from max.views import BaseView
from ..base_user_views import BackToUsersMixin, FetchUserMixin
class UserToggleEnabled(FetchUserMixin, BackToUsersMixin, BaseView):
def post(self):
self.user.enabled = not self.user.enabled
update_user(self.user)
_, back_url = self.get_back_text_and_url()
return self.redirect(back_url)

131
max/sessions.py Normal file
View File

@ -0,0 +1,131 @@
from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin
from itsdangerous import BadSignature, Signer, want_bytes
from werkzeug.datastructures import CallbackDict
from max.crypto import get_random_string
class SessionStorage:
def __init__(self, maxcap=100):
self.maxcap = maxcap
self.lru_ids = [] # newest ids are on the end (so do append to add a fresh id)
self.data = {}
def __getitem__(self, id):
if id not in self.data:
return None
self._refresh_id(id)
return self.data[id]
def __delitem__(self, id):
if id in self.data:
del self.data[id]
self._remove_id(id)
def __setitem__(self, id, data):
self._refresh_id(id)
self.data[id] = data
while len(self.lru_ids) > self.maxcap:
removed_id = self.lru_ids.pop(0) # remove first element
del self.data[removed_id]
def _remove_id(self, id):
self.lru_ids = [_id for _id in self.lru_ids if _id != id]
def _refresh_id(self, id):
self._remove_id(id)
self.lru_ids.append(id)
def remove_user_sessions(self, user_id, except_id=None):
ids_to_remove = []
for id, data in self.data.items():
if data.get("user_id", None) == user_id and id != except_id:
ids_to_remove.append(id)
for id in ids_to_remove:
del self.data[id]
self._remove_id(id)
session_storage = SessionStorage()
class Session(CallbackDict, SessionMixin):
def __init__(self, id=None, data=None):
def on_update(self):
self.modified = True
if not self.id:
self.set_new_id()
self.id = id
self.modified = False
CallbackDict.__init__(self, data, on_update)
def is_empty(self):
return len(self) == 0
def clear(self):
self.id = None
super().clear()
def set_new_id(self):
self.id = get_random_string(32)
class SessionInterface(FlaskSessionInterface):
def _get_signer(self, app):
if not app.secret_key:
return None
return Signer(app.secret_key, salt="flask-session", key_derivation="hmac")
def get_cookie_name(self, app):
return app.session_cookie_name
def make_session_object(self, session_id=None, data=None):
return Session(session_id, data=data)
def open_session(self, app, request):
session_id = request.cookies.get(self.get_cookie_name(app))
if not session_id:
return self.make_session_object()
signer = self._get_signer(app)
if signer is None:
return None
try:
sid_as_bytes = signer.unsign(session_id)
session_id = sid_as_bytes.decode()
except BadSignature:
return self.make_session_object()
if not isinstance(session_id, str):
session_id = session_id.decode("utf-8", "strict")
data = session_storage[session_id]
return self.make_session_object(session_id, data=data)
def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
cookie_name = self.get_cookie_name(app)
if session.is_empty():
if session.modified:
del session_storage[session.id]
response.delete_cookie(cookie_name, domain=domain, path=path)
else:
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
session_storage[session.id] = dict(session)
session_id = self._get_signer(app).sign(want_bytes(session.id))
response.set_cookie(
cookie_name,
session_id,
expires=expires,
httponly=httponly,
domain=domain,
path=path,
secure=secure,
)

BIN
max/static/arrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

223
max/static/base.css Normal file
View File

@ -0,0 +1,223 @@
body {
font-size: 16px;
}
table {
width: 100%;
margin-bottom: 20px;
white-space: nowrap;
}
a:link, a:visited, a:hover, a:active {
color: black;
text-decoration: none;
}
a:hover {
color: black;
text-decoration: underline;
}
form {
margin-bottom: 20px;
}
.margin-top {
margin-top: 1em;
}
.pure-form-aligned .pure-controls.checkbox {
margin: .6em 0 .5em 11em;
}
.header {
width: 100%;
white-space: nowrap;
position: fixed;
left: 0;
top: 0;
text-align: center;
box-shadow: 0 1px 1px rgba(0,0,0, 0.10);
font-size: 20px;
line-height: 1em;
background: #363537;
color: white;
border-bottom: none;
z-index: 4;
}
.header .heading, .header .side {
padding: 1em;
text-decoration: none;
white-space: nowrap;
}
.header .side.left img {
height: 1em;
margin-right: .5em;
}
.header .side div, .header .main div {
display: inline-block;
vertical-align: top;
}
.header a, .header a:link, .header a:visited, .header a:hover, .header a:active {
color: white;
}
.header .main .title {
font-weight: bold;
text-transform: uppercase;
background-color: black;
padding: 1em 0;
height: 1em;
width: 5em;
}
.header .main .right, .header .main .left {
height: 3em;
width: 6em;
}
.header .main .left {
background: rgb(54,53,55);
background: linear-gradient(90deg, rgba(54,53,55,1) 0%, rgba(0,0,0,1) 100%);
}
.header .main .right {
background: rgb(54,53,55);
background: linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(54,53,55,1) 100%);
}
.header .main img {
height: 3em;
}
.content-wrapper {
width: 100%;
min-height: 100%;
position: absolute;
top: 0;
background-color: #EEEEEE;
}
.content {
width: 60%;
margin: 70px auto 20px auto;
padding: 5px 15px;
background-color: #FFFFFF;
border: 1px solid #CCCCCC;
}
.top-row-button {
margin: 1.1em 0;
float: right;
}
.inline-form {
display: inline-block;
margin: 0;
margin-left: 0.5em;
}
.logout-form {
font-size: 12px;
margin-left: 2em;
}
.logout-form > button {
padding: 4px .75em;
line-height: 1em;
}
.pure-form-stacked label {
margin-top: 1em;
}
.no-aliases {
margin-bottom: 20px;
text-align: center;
color: gray;
}
.detail {
margin-bottom: 20px;
}
.detail > div {
margin-bottom: 8px;
}
.detail b {
width: 4.5em;
display: inline-block;
}
.detail form {
margin: 0;
}
@media (max-width: 1200px) {
body {
font-size: 13px;
}
table {
white-space: normal;
}
.header {
font-size: 16px;
}
.header .main .title {
background-color: inherit;
}
.header .main .right, .header .main .left, .header .main .logo {
display: none;
}
.content {
width: calc(100% - 72px);
margin: 60px 20px 20px 20px;
}
}
@media (max-width: 960px) {
.optional {
display: none;
}
}
.msg-success,
.msg-error,
.msg-warning,
.msg-message {
color: white;
border-radius: 4px;
padding: 15px 0 20px 30px;
margin: 15px 0;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.msg-success {
background: rgb(28, 184, 65);
}
.msg-error {
background: rgb(202, 60, 60);
}
.msg-warning {
background: rgb(223, 117, 20);
}
.msg-message {
background: rgb(66, 184, 221);
}
.button-error {
background: rgb(202, 60, 60);
}
.button-success {
background: rgb(28, 184, 65);
}
.button-warning {
background: rgb(223, 117, 20);
}
.button-secondary {
background: rgb(66, 184, 221);
}
.button-success, .button-error, .button-warning, .button-secondary {
color: white;
border-radius: 4px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}

BIN
max/static/eyes-small.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
max/static/eyes.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

11
max/static/pure-min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="msg-{{ category }}">{{ message }}</div>
{% endfor %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<h1>Create new alias for {{ user.email }}</h1>
{% include "alerts.html" %}
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('alias-create', user_id=user.id) }}" method="post">
<div class="pure-control-group">
<label for="email">Email</label>
<input id="email" type="email" name="email" {% if email is defined %}value="{{ email }}"{% endif %} placeholder="Email">
</div>
<div class="pure-controls checkbox">
<label for="enabled">
<input id="enabled" type="checkbox" name="enabled" {% if enabled %}checked{% endif %}> Enabled
</label>
</div>
<div class="pure-control-group">
<label for="note">Note</label>
<textarea id="note" name="note" form="form">{% if note is defined %}{{ note }}{% endif %}</textarea>
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Create</button>
<a href="{{ url_for('user-detail', user_id=user.id) }}" class="button-small pure-button">Cancel</a>
</div>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Delete alias {{ alias.email }}?</h1>
{% include "alerts.html" %}
<form action="{{ url_for('alias-delete', alias_id=alias.id, **request.args) }}" method="post">
<button type="submit" class="pure-button button-error">Delete</button>
<a href="{{ url_for('user-detail', user_id=user.id) }}" class="pure-button">Cancel</a>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<h1>Alias {{ alias.email }} for {{ user.email }}</h1>
{% include "alerts.html" %}
<div class="detail">
<div>
<b>Enabled:</b>
<form class="inline-form optional" action="{{ url_for('alias-toggle-enabled', alias_id=alias.id, return='detail') }}" method="post">
{{ alias.enabled }}
<button type="submit" class="button-small pure-button">Change</button>
{% include "csrf.html" %}
</form>
</div><div>
<b>Note:</b>{% if alias.note %}{{ alias.note }}{% else %}&ndash;{% endif %}
<a href="{{ url_for('alias-edit-note', alias_id=alias.id, return='detail') }}" class="button-small pure-button optional">Edit</a>
</div><div>
<a href="{{ url_for('alias-delete', alias_id=alias.id, return='detail') }}" class="button-small pure-button">Delete</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<h1>Edit note for {{ alias.email }}</h1>
{% include "alerts.html" %}
<form id="form" class="pure-form pure-form-stacked" action="{{ url_for('alias-edit-note', alias_id=alias.id, **request.args) }}" method="post">
<label for="note">Note</label>
<textarea id="note" name="note" form="form">{{ alias.note }}</textarea>
<div class="margin-top">
<button type="submit" class="pure-button pure-button-primary">Save</button>
<a href="{{ url_for('user-detail', user_id=user.id) }}" class="button-small pure-button">Cancel</a>
</div>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block content %}
<h1>Login</h1>
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="msg-{{ category }}">{{ message }}</div>
{% endfor %}
{% if alert %}
<div class="msg-error">Please login to visit the requested url</div>
{% endif %}
<form class="pure-form" action="/login" method="post">
<fieldset>
<input type="email" name="email" {% if email %}value="{{ email }}"{% endif %} placeholder="Email" autocomplete="email">
<input type="password" name="password" placeholder="Password" autocomplete="current-password">
<button type="submit" class="pure-button pure-button-primary">Log in</button>
</fieldset>
</form>
{% endblock %}

62
max/templates/base.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ webpagetitle }} &ndash; MAX</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<link rel="stylesheet" href="{{ url_for('static', filename='pure-min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
<link rel="icon" type="image/png" href="{{ url_for('favicon') }}">
</head>
<body>
<div class="header">
<div class="pure-g">
<div class="pure-u-1-3">
{% if back_text is defined %}
<a href="{{ back_url }}"><div class="side left">
<div>
<img src="{{ url_for('static', filename='arrow.png') }}">
</div><div>
{{ back_text }}
</div>
</div></a>
{% endif %}
</div>
<div class="pure-u-1-3">
<a href="{{ url_for('user-list') }}"><div class="main">
<div class="left">
</div><div class="logo">
<img src="{{ url_for('static', filename='eyes-small.jpg') }}">
</div><div class="title">
MAX
</div><div class="right">
</div>
</div></a>
</div>
<div class="pure-u-1-3">
<div class="side right">
{% if session['user_id'] %}
<div>
<span>Hi {{ session['user_email'] }}!</span>
</div><div>
<form action="{{ url_for('logout') }}" method="post" class="logout-form inline-form">
<button type="submit" class="button-small pure-button">Logout</button>
{% include "csrf.html" %}
</form>
</div>
{% else %}
You are not logged in
{% endif %}
</div>
</div>
</div>
</div>
<div class="content-wrapper">
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
</body>
</html>

1
max/templates/csrf.html Normal file
View File

@ -0,0 +1 @@
{% if session['csrf_token'] %}<input type="hidden" name="csrf_token" value="{{ session['csrf_token'] }}">{% endif %}

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<h1>Change password for {{ user.email }}</h1>
{% include "alerts.html" %}
<form class="pure-form pure-form-aligned" action="{{ url_for('user-change-password', user_id=user.id, **request.args) }}" method="post">
<div class="pure-control-group">
<label for="password1">Password</label>
<input id="password1" type="password" name="password1" placeholder="Password">
</div>
<div class="pure-control-group">
<label for="password2">Reenter Password</label>
<input id="password2" type="password" name="password2" placeholder="Reenter Password">
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Change password</button>
<a href="{{ url_for('user-list') }}" class="button-small pure-button">Cancel</a>
</div>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<h1>Create new user</h1>
{% include "alerts.html" %}
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('user-create') }}" method="post">
<div class="pure-control-group">
<label for="email">Email</label>
<input id="email" type="email" name="email" {% if email is defined %}value="{{ email }}"{% endif %} placeholder="Email">
</div>
<div class="pure-control-group">
<label for="password1">Password</label>
<input id="password1" type="password" name="password1" {% if password is defined %}value="{{ password }}"{% endif %} placeholder="Password">
</div>
<div class="pure-control-group">
<label for="password2">Reenter Password</label>
<input id="password2" type="password" name="password2" {% if password is defined %}value="{{ password }}"{% endif %} placeholder="Reenter Password">
</div>
<div class="pure-controls checkbox">
<label for="enabled">
<input id="enabled" type="checkbox" name="enabled" {% if enabled %}checked{% endif %}> Enabled
</label>
</div>
<div class="pure-control-group">
<label for="note">Note</label>
<textarea id="note" name="note" form="form">{% if note is defined %}{{ note }}{% endif %}</textarea>
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Create</button>
<a href="{{ url_for('user-list') }}" class="button-small pure-button">Cancel</a>
</div>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Delete {{ user.email }}?</h1>
{% include "alerts.html" %}
<form action="{{ url_for('user-delete', user_id=user.id, **request.args) }}" method="post">
<button type="submit" class="pure-button button-error">Delete</button>
<a href="{{ url_for('user-list') }}" class="pure-button">Cancel</a>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block content %}
<div class="pure-g">
<div class="pure-u-4-5">
<h1>{{ user.email }}</h1>
</div>
<div class="pure-u-1-5">
<a href="{{ url_for('alias-create', user_id=user.id) }}" class="top-row-button pure-button">Create alias</a>
</div>
</div>
{% include "alerts.html" %}
<div class="detail">
<div>
<b>Enabled:</b>
<form class="inline-form optional" action="{{ url_for('user-toggle-enabled', user_id=user.id, return='detail') }}" method="post">
{{ user.enabled }}
<button type="submit" class="button-small pure-button">Change</button>
{% include "csrf.html" %}
</form>
</div><div>
<b>Note:</b>{% if user.note %}{{ user.note }}{% else %}&ndash;{% endif %}
<a href="{{ url_for('user-edit-note', user_id=user.id, return='detail') }}" class="button-small pure-button optional">Edit</a>
</div><div>
<a href="{{ url_for('user-change-password', user_id=user.id, return='detail') }}" class="button-small pure-button">Change password</a>
<a href="{{ url_for('user-delete', user_id=user.id, return='detail') }}" class="button-small pure-button">Delete</a>
</div>
</div>
{% if aliases|length > 0 %}
<table class="pure-table">
<thead>
<tr>
<th>Email</th>
<th>Enabled</th>
<th>Note</th>
<th class="optional">Actions</th>
</tr>
</thead>
<tbody>
{% for alias in aliases %}
<tr>
<td>
<a href="{{ url_for('alias-detail', alias_id=alias.id) }}">{{ alias.email }}</a>
</td>
<td>
{{ alias.enabled }}
<form class="inline-form optional" action="{{ url_for('alias-toggle-enabled', alias_id=alias.id) }}" method="post">
<button type="submit" class="button-small pure-button">Change</button>
{% include "csrf.html" %}
</form>
</td>
<td>
{{ alias.note }}
<a href="{{ url_for('alias-edit-note', alias_id=alias.id) }}" class="button-small pure-button optional">Edit</a>
</td>
<td class="optional">
<a href="{{ url_for('alias-delete', alias_id=alias.id) }}" class="button-small pure-button">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-aliases">No aliases yet</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<h1>Edit note for {{ user.email }}</h1>
{% include "alerts.html" %}
<form id="form" class="pure-form pure-form-stacked" action="{{ url_for('user-edit-note', user_id=user.id, **request.args) }}" method="post">
<label for="note">Note</label>
<textarea id="note" name="note" form="form">{{ user.note }}</textarea>
<div class="margin-top">
<button type="submit" class="pure-button pure-button-primary">Save</button>
<a href="{{ back_url }}" class="button-small pure-button">Cancel</a>
</div>
{% include "csrf.html" %}
</form>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<div class="pure-g">
<div class="pure-u-4-5">
<h1>Users</h1>
</div>
<div class="pure-u-1-5">
<a href="{{ url_for('user-create') }}" class="top-row-button pure-button">Create user</a>
</div>
</div>
{% include "alerts.html" %}
<table class="pure-table">
<thead>
<tr>
<th>Email</th>
<th>Enabled</th>
<th>Note</th>
<th class="optional">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<a href="{{ url_for('user-detail', user_id=user.id) }}">{{ user.email }}</a>
</td>
<td>
{{ user.enabled }}
<form class="inline-form optional" action="{{ url_for('user-toggle-enabled', user_id=user.id) }}" method="post">
<button type="submit" class="button-small pure-button">Change</button>
{% include "csrf.html" %}
</form>
</td>
<td>
{% if user.note %}{{ user.note }}{% else %}&ndash;{% endif %}
<a href="{{ url_for('user-edit-note', user_id=user.id) }}" class="button-small pure-button optional">Edit</a>
</td>
<td class="optional">
<a href="{{ url_for('user-change-password', user_id=user.id) }}" class="button-small pure-button">Change password</a>
<a href="{{ url_for('user-delete', user_id=user.id) }}" class="button-small pure-button">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

0
max/utils.py Normal file
View File

118
max/views.py Normal file
View File

@ -0,0 +1,118 @@
from functools import update_wrapper
from flask import current_app, redirect as flask_redirect, render_template, request
from werkzeug.exceptions import MethodNotAllowed
from max.exceptions import ImproperlyConfigured
def redirect(url):
return flask_redirect(url, code=302)
class BaseView:
url = None
http_method_names = [
"get",
"post",
"put",
"patch",
"delete",
"head",
"options",
"trace",
]
def __init__(self, *args, **kwargs):
self.request = request
self.app = current_app
for key, value in kwargs.items():
setattr(self, key, value)
@classmethod
def as_view(cls):
def view(*args, **kwargs):
self = cls(*args, **kwargs)
return self.dispatch()
# take name and docstring from class
update_wrapper(view, cls, updated=())
# and possible attributes set by decorators
# like csrf_exempt from dispatch
update_wrapper(view, cls.dispatch, assigned=())
return view
def dispatch(self):
method = request.method.lower()
handler = (
getattr(self, method, None) if method in self.http_method_names else None
)
if not handler:
return self.method_not_allowed_handler()
before_handler_result = self.before_handler()
if before_handler_result is not None:
return before_handler_result
return handler()
def before_handler(self):
""" if something is returned, it is returned as response. """
pass
def method_not_allowed_handler(self):
raise MethodNotAllowed()
def redirect(self, url):
return redirect(url)
class BaseTemplateView(BaseView):
template_name = None
webpagetitle = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.template_name is None:
raise ImproperlyConfigured()
self.context = {}
def get_context(self):
self.context["webpagetitle"] = self.get_webpagetitle()
back_text_and_url = self.get_back_text_and_url()
if back_text_and_url is not None:
self.context["back_text"] = back_text_and_url[0]
self.context["back_url"] = back_text_and_url[1]
return self.context
def get_webpagetitle(self):
return self.webpagetitle
def get_back_text_and_url(self):
""" Return None to hide. Return (str, str) for display name and url """
return None
def redirect(self, url=None):
if not url:
ret = self.get_back_text_and_url()
if ret is None:
raise ImproperlyConfigured()
_, url = ret
return super().redirect(url)
def render(self):
context = self.get_context()
return render_template(self.template_name, **context)
def dispatch(self):
result = super().dispatch()
if result is None:
return self.render()
return result
class BaseTemplateGetView(BaseTemplateView):
def get(self):
pass

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
black
isort
flake8

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask
psycopg2
gunicorn

13
setup.cfg Normal file
View File

@ -0,0 +1,13 @@
[flake8]
max_line_length = 96
[isort]
include_trailing_comma = true
multi_line_output = 3
lines_after_imports = 2
combine_as_imports = true
force_grid_wrap = 0
use_parentheses = true
line_length = 88
known_first_party = max
known_third_party = flask

8
workspace.code-workspace Normal file
View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}