initial commit
114
.vscode/.ropeproject/config.py
vendored
Normal 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
15
Dockerfile
Normal 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
@ -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
@ -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
@ -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 .
|
||||||
7
cleanup.sh
Executable 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
@ -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
@ -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
@ -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
83
max/app.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,2 @@
|
|||||||
|
class ImproperlyConfigured(Exception):
|
||||||
|
pass
|
||||||
70
max/middlewares.py
Normal 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
|
||||||
BIN
max/private_static/favicons/cat-face_1f431.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
max/private_static/favicons/cat-with-tears-of-joy_1f639.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
max/private_static/favicons/cat_1f408.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
BIN
max/private_static/favicons/grinning-cat_1f63a.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
max/private_static/favicons/kissing-cat_1f63d.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
max/private_static/favicons/paw-prints_1f43e.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
max/private_static/favicons/pouting-cat_1f63e.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
max/private_static/favicons/weary-cat_1f640.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
2
max/private_static/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
63
max/routes/__init__.py
Normal 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())),
|
||||||
|
)
|
||||||
57
max/routes/alias/create.py
Normal 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
|
||||||
14
max/routes/alias/delete.py
Normal 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))
|
||||||
8
max/routes/alias/detail.py
Normal 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
|
||||||
24
max/routes/alias/edit_note.py
Normal 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()
|
||||||
12
max/routes/alias/toggle_enabled.py
Normal 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)
|
||||||
31
max/routes/base_alias_views.py
Normal 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)
|
||||||
27
max/routes/base_user_views.py
Normal 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
@ -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
@ -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")
|
||||||
31
max/routes/user/change_password.py
Normal 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
@ -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
@ -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
@ -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
|
||||||
24
max/routes/user/edit_note.py
Normal 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
@ -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
|
||||||
12
max/routes/user/toggle_enabled.py
Normal 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
@ -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
|
After Width: | Height: | Size: 662 B |
223
max/static/base.css
Normal 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
|
After Width: | Height: | Size: 7.5 KiB |
BIN
max/static/eyes.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
11
max/static/pure-min.css
vendored
Normal file
3
max/templates/alerts.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||||
|
<div class="msg-{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
30
max/templates/alias/create.html
Normal 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 %}
|
||||||
12
max/templates/alias/delete.html
Normal 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 %}
|
||||||
22
max/templates/alias/detail.html
Normal 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 %}–{% 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 %}
|
||||||
17
max/templates/alias/edit-note.html
Normal 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 %}
|
||||||
20
max/templates/auth/login.html
Normal 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
@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ webpagetitle }} – 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
@ -0,0 +1 @@
|
|||||||
|
{% if session['csrf_token'] %}<input type="hidden" name="csrf_token" value="{{ session['csrf_token'] }}">{% endif %}
|
||||||
24
max/templates/user/change-password.html
Normal 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 %}
|
||||||
40
max/templates/user/create.html
Normal 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 %}
|
||||||
12
max/templates/user/delete.html
Normal 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 %}
|
||||||
69
max/templates/user/detail.html
Normal 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 %}–{% 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 %}
|
||||||
17
max/templates/user/edit-note.html
Normal 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 %}
|
||||||
50
max/templates/user/list.html
Normal 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 %}–{% 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
118
max/views.py
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
black
|
||||||
|
isort
|
||||||
|
flake8
|
||||||
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask
|
||||||
|
psycopg2
|
||||||
|
gunicorn
|
||||||
13
setup.cfg
Normal 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
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||