Browse Source

Add initial API base

pull/1/head
Dashie der otter 5 months ago
parent
commit
7438e36f0a
Signed by: dashie <rhaamo@leloop.org> GPG Key ID: C2D57B325840B755
29 changed files with 1495 additions and 0 deletions
  1. +37
    -0
      .gitignore
  2. +22
    -0
      .tmuxinator.yml
  3. +42
    -0
      .vscode/settings.json
  4. +69
    -0
      api/.dockerignore
  5. +260
    -0
      api/app.py
  6. +5
    -0
      api/commands/__init__.py
  7. +39
    -0
      api/commands/db_datas.py
  8. +32
    -0
      api/commands/roles.py
  9. +56
    -0
      api/commands/system.py
  10. +87
    -0
      api/commands/users.py
  11. +141
    -0
      api/config/config.py
  12. +5
    -0
      api/config/development.py
  13. +33
    -0
      api/config/production_secret_sample.py
  14. +25
    -0
      api/config/testing.py
  15. +1
    -0
      api/migrations/README
  16. +45
    -0
      api/migrations/alembic.ini
  17. +96
    -0
      api/migrations/env.py
  18. +24
    -0
      api/migrations/script.py.mako
  19. +69
    -0
      api/migrations/versions/000_77dc08b8493f_.py
  20. +117
    -0
      api/models.py
  21. +13
    -0
      api/pyproject.toml
  22. +21
    -0
      api/requirements.txt
  23. +26
    -0
      api/setup.cfg
  24. +31
    -0
      api/setup.py
  25. +0
    -0
      api/utils/__init__.py
  26. +82
    -0
      api/utils/flake_id.py
  27. +112
    -0
      api/utils/various.py
  28. +1
    -0
      api/version.py
  29. +4
    -0
      babel.cfg

+ 37
- 0
.gitignore View File

@@ -0,0 +1,37 @@
*.pyc
/venv
/config.py
/.idea
*.db
/uploads/*/*
/tmp/cty.xml
/.pytest_cache/
/api/*.egg-info
/api/.eggs
/.eggs
/configtest.py
/api/*.log
.coverage
.ropeproject
/.mypy_cache
/api/test-reports
test/unit/coverage/
test/e2e/reports
.DS_Store
/docs-build
/docs/_build/

api/config/*_secret.py

# Translations
*.mo

*~

front/src/translations.json
front/src/translations/*.json
front/locales/*/LC_MESSAGES/app.po

.env
/data
/api/celerybeat-schedule

+ 22
- 0
.tmuxinator.yml View File

@@ -0,0 +1,22 @@
# .tmuxinator.yml

name: dev
root: .

windows:
- backend:
- export FLASK_ENV=development
- export APP_SETTINGS='config.development_secret.Config'
- source venv/bin/activate
- cd api
- flask run
# - workers:
# - export FLASK_ENV=development
# - export APP_SETTINGS='config.development_secret.Config'
# - source venv/bin/activate
# - cd api
# - celery worker -A tasks.celery --loglevel=error
- frontend:
- cd front
- yarn dev


+ 42
- 0
.vscode/settings.json View File

@@ -0,0 +1,42 @@
{
"python.linting.pylintEnabled": false,
"python.linting.flake8Enabled": true,
"python.linting.enabled": true,
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/node_modueles": true,
"**/__pycache__": true,
"**/*.egg-info": true
},
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"eslint.autoFixOnSave": true,
"eslint.validate": [
{
"language": "vue",
"autoFix": true
},
{
"language": "html",
"autoFix": true
},
{
"language": "javascript",
"autoFix": true
},
{
"language": "typescript",
"autoFix": true
}
],
"vetur.format.defaultFormatter.js": "vscode-typescript",
"vetur.format.defaultFormatter.html": "js-beautify-html",
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"python.pythonPath": "/home/dashie/dev/omnomnomnom/venv/bin/python",
"restructuredtext.confPath": "${workspaceFolder}/docs"
}

+ 69
- 0
api/.dockerignore View File

@@ -0,0 +1,69 @@
### OSX ###
.DS_Store
.AppleDouble
.LSOverride

### SublimeText ###
# cache files for sublime text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache

# workspace files are user-specific
*.sublime-workspace

# project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using SublimeText
# *.sublime-project

# sftp configuration file
sftp-config.json

# Basics
*.py[cod]
__pycache__

# Logs
*.log
pip-log.txt

# Unit test / coverage reports
.coverage
.tox
nosetests.xml
htmlcov

# Translations
*.mo
*.pot

# Pycharm
.idea

# Vim

*~
*.swp
*.swo

# npm
front/node_modules/

# Compass
.sass-cache

# virtual environments
.env

# User-uploaded media
uploads/

# Hitch directory
tests/.hitch

# MailHog binary
mailhog

*.sqlite3
music
media

+ 260
- 0
api/app.py View File

@@ -0,0 +1,260 @@
# encoding: utf-8
import logging
import os
import subprocess
from logging.handlers import RotatingFileHandler
from flask_babelex import gettext, Babel
from flask import Flask, g, send_from_directory, jsonify, safe_join, request, flash, Response
from flask_mail import Mail
from flask_migrate import Migrate
from flask_security import Security
from flask_security import signals as FlaskSecuritySignals
from flask_cors import CORS, cross_origin
from flasgger import Swagger
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import import_string
import commands
from utils.flake_id import FlakeId
from sqlalchemy import event
from sqlalchemy.engine import Engine
import time

from models import db, user_datastore
from utils.various import InvalidUsage, is_admin

from celery import Celery

from version import VERSION

__VERSION__ = VERSION

AVAILABLE_LOCALES = ["fr", "fr_FR", "en", "en_US", "pl"]

try:
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration as SentryFlaskIntegration
from sentry_sdk.integrations.celery import CeleryIntegration as SentryCeleryIntegration

print(" * Sentry Flask/Celery support have been loaded")
HAS_SENTRY = True
except ImportError:
print(" * No Sentry Flask/Celery support available")
HAS_SENTRY = False

GIT_VERSION = ""
gitpath = os.path.join(os.getcwd(), "../.git")
if os.path.isdir(gitpath):
GIT_VERSION = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
if GIT_VERSION:
GIT_VERSION = GIT_VERSION.strip().decode("UTF-8")


def make_celery(remoulade):
celery = Celery(remoulade.import_name, broker=remoulade.config["CELERY_BROKER_URL"])
celery.conf.update(remoulade.config)
TaskBase = celery.Task

class ContextTask(TaskBase):
abstract = True

def __call__(self, *args, **kwargs):
with remoulade.app_context():
return TaskBase.__call__(self, *args, **kwargs)

celery.Task = ContextTask
return celery # omnomnom


def create_app(config_filename="config.development.Config", app_name=None, register_blueprints=True):
# App configuration
app = Flask(app_name or __name__)
app_settings = os.getenv("APP_SETTINGS", config_filename)
print(f" * Loading config: '{app_settings}'")
try:
cfg = import_string(app_settings)()
except ImportError:
print(" *** Cannot import config ***")
cfg = import_string("config.config.BaseConfig")
print(" *** Default config loaded, expect problems ***")
app.config.from_object(cfg)

app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)

app.jinja_env.add_extension("jinja2.ext.with_")
app.jinja_env.add_extension("jinja2.ext.do")
app.jinja_env.globals.update(is_admin=is_admin)

if HAS_SENTRY:
sentry_sdk.init(
app.config["SENTRY_DSN"],
integrations=[SentryFlaskIntegration(), SentryCeleryIntegration()],
release=f"{VERSION} ({GIT_VERSION})",
)
print(" * Sentry Flask/Celery support activated")
print(" * Sentry DSN: %s" % app.config["SENTRY_DSN"])

if app.debug:
app.jinja_env.auto_reload = True
logging.basicConfig(level=logging.DEBUG)

# Logging
if not app.debug:
formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]")
file_handler = RotatingFileHandler("%s/errors_app.log" % os.getcwd(), "a", 1000000, 1)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(formatter)
app.logger.addHandler(file_handler)

dbLogger = logging.getLogger("omnomnomnom.sqltime")
dbLogger.setLevel(logging.DEBUG)

CORS(app, origins=["*"])

if app.debug:
logging.getLogger("flask_cors.extension").level = logging.DEBUG

mail = Mail(app) # noqa: F841
migrate = Migrate(app, db) # noqa: F841 lgtm [py/unused-local-variable]
babel = Babel(app) # noqa: F841
app.babel = babel

template = {
"swagger": "2.0",
"info": {"title": "omnomnomnom API", "description": "API", "version": VERSION},
"host": app.config["APP_HOST"],
"basePath": "/",
"schemes": ["https"],
"securityDefinitions": {
},
"consumes": ["application/json"],
"produces": ["application/json"],
}

db.init_app(app)

# Setup Flask-Security
security = Security(app, user_datastore) # noqa: F841 lgtm [py/unused-local-variable]

@FlaskSecuritySignals.password_reset.connect_via(app)
@FlaskSecuritySignals.password_changed.connect_via(app)
def log_password_reset(sender, user):
if not user:
return
add_user_log(user.id, user.id, "user", "info", "Your password has been changed !")

@FlaskSecuritySignals.reset_password_instructions_sent.connect_via(app)
def log_reset_password_instr(sender, user, token):
if not user:
return
add_user_log(user.id, user.id, "user", "info", "Password reset instructions sent.")

@security.mail_context_processor
def mail_ctx_proc():
return dict()

@babel.localeselector
def get_locale():
# if a user is logged in, use the locale from the user settings
identity = getattr(g, "identity", None)
if identity is not None and identity.id:
return identity.user.locale
# otherwise try to guess the language from the user accept
# header the browser transmits. We support fr/en in this
# example. The best match wins.
return request.accept_languages.best_match(AVAILABLE_LOCALES)

@babel.timezoneselector
def get_timezone():
identity = getattr(g, "identity", None)
if identity is not None and identity.id:
return identity.user.timezone


@app.before_request
def before_request():

cfg = {
"OMNOMNOMNOM_VERSION_VER": VERSION,
"OMNOMNOMNOM_VERSION_GIT": GIT_VERSION,
}
if GIT_VERSION:
cfg["OMNOMNOMNOM_VERSION"] = "{0}-{1}".format(VERSION, GIT_VERSION)
else:
cfg["OMNOMNOMNOM_VERSION"] = VERSION

g.cfg = cfg

@app.errorhandler(InvalidUsage)
def handle_invalid_usage(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
return response

@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
if not False:
return
conn.info.setdefault("query_start_time", []).append(time.time())
dbLogger.debug("Start Query: %s", statement)

@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
if not False:
return
total = time.time() - conn.info["query_start_time"].pop(-1)
dbLogger.debug("Query Complete!")
dbLogger.debug("Total Time: %f", total)

app.flake_id = FlakeId()

if register_blueprints:
# from controllers.api.v1.Xm import bp_X
# app.register_blueprint(bp_X)

swagger = Swagger(app, template=template) # noqa: F841 lgtm [py/unused-local-variable]

@app.route("/uploads/<string:thing>/<path:stuff>", methods=["GET"])
@cross_origin(origins="*", methods=["GET", "HEAD", "OPTIONS"], expose_headers="content-length", send_wildcard=True)
def get_uploads_stuff(thing, stuff):
if app.testing or app.debug:
directory = safe_join(app.config["UPLOADS_DEFAULT_DEST"], thing)
app.logger.debug(f"serving {stuff} from {directory}")
return send_from_directory(directory, stuff, as_attachment=True)
else:
app.logger.debug(f"X-Accel-Redirect serving {stuff}")
resp = Response("")
resp.headers["Content-Disposition"] = f"attachment; filename={stuff}"
resp.headers["X-Accel-Redirect"] = f"/_protected/media/{thing}/{stuff}"
resp.headers["Content-Type"] = "" # empty it so Nginx will guess it correctly
return resp

@app.errorhandler(404)
def page_not_found(msg):
return jsonify({"error": "page not found"}), 404

@app.errorhandler(403)
def err_forbidden(msg):
return jsonify({"error": "access forbidden"}), 403

@app.errorhandler(410)
def err_gone(msg):
return jsonify({"error": "gone"}), 410

if not app.debug:

@app.errorhandler(500)
def err_failed(msg):
return jsonify({"error": "server error"}), 500

@app.after_request
def set_x_powered_by(response):
response.headers["X-Powered-By"] = "omnomnomnom"
return response

# Register CLI commands
app.cli.add_command(commands.db_datas)
app.cli.add_command(commands.users)
app.cli.add_command(commands.roles)
app.cli.add_command(commands.system)

return app

+ 5
- 0
api/commands/__init__.py View File

@@ -0,0 +1,5 @@
# flake8: noqa: F401
from .db_datas import db_datas
from .users import users
from .roles import roles
from .system import system

+ 39
- 0
api/commands/db_datas.py View File

@@ -0,0 +1,39 @@
import click
from models import db, Role
from flask.cli import with_appcontext


@click.group()
def db_datas():
"""
Datas migrations sometimes needed.

Run them only one time unless specified BREAKING.
"""
pass


def make_db_seed(db):
# roles
roles = db.session.query(Role.name).all()
roles = [r[0] for r in roles]
if "user" not in roles:
role_usr = Role(name="user", description="Simple user")
db.session.add(role_usr)
if "admin" not in roles:
role_adm = Role(name="admin", description="Admin user")
db.session.add(role_adm)

# Final commit
db.session.commit()


@db_datas.command(name="000-seeds")
@with_appcontext
def seeds():
"""
Seed database with default config and roles values

non breaking.
"""
make_db_seed(db)

+ 32
- 0
api/commands/roles.py View File

@@ -0,0 +1,32 @@
import click
import texttable
from models import Role
from flask.cli import with_appcontext


@click.group()
def roles():
"""
Role commands.
"""
pass


@roles.command(name="list")
@with_appcontext
def list():
"""
List roles.
"""
roles = Role.query.order_by(Role.id.asc())

table = texttable.Texttable()
table.set_deco(texttable.Texttable().HEADER)
table.set_cols_dtype(["t", "t"])
table.set_cols_align(["l", "l"])
table.add_rows([["Name", "Description"]])

for role in roles.all():
table.add_row([role.name, role.description])

print(table.draw())

+ 56
- 0
api/commands/system.py View File

@@ -0,0 +1,56 @@
import click
import sys
from flask.cli import with_appcontext
from flask_mail import Message
from flask import render_template, current_app
import texttable


@click.group()
def system():
"""
System commands.
"""
pass


@system.command(name="test-email")
@click.option("--email", prompt=True, help="Email to send the test to")
@with_appcontext
def test_email(email):
"""
Test email sending.
"""
mail = current_app.extensions.get("mail")
if not mail:
print("ERROR: mail extensions is None !!!")
exit(-1)

msg = Message(subject="omnomnomnom test email", recipients=[email], sender=current_app.config["MAIL_DEFAULT_SENDER"])
msg.body = render_template("email/test_email.txt")
msg.html = render_template("email/test_email.html")
try:
mail.send(msg)
except: # noqa: E722
print(f"Error sending mail: {sys.exc_info()[0]}")


@system.command(name="routes")
@with_appcontext
def routes():
"""
Dump all routes of defined app
"""
table = texttable.Texttable()
table.set_deco(texttable.Texttable().HEADER)
table.set_cols_dtype(["t", "t", "t"])
table.set_cols_align(["l", "l", "l"])
table.set_cols_width([50, 30, 80])

table.add_rows([["Prefix", "Verb", "URI Pattern"]])

for rule in sorted(current_app.url_map.iter_rules(), key=lambda x: str(x)):
methods = ",".join(rule.methods)
table.add_row([rule.endpoint, methods, rule])

print(table.draw())

+ 87
- 0
api/commands/users.py View File

@@ -0,0 +1,87 @@
import click
from models import db, user_datastore, Role, User
from flask.cli import with_appcontext
from flask import current_app
from flask_security.utils import hash_password
from flask_security import confirmable as FSConfirmable
import texttable
import datetime


@click.group()
def users():
"""
User commands.
"""
pass


@users.command(name="list")
@with_appcontext
def list():
"""
List local users.
"""
users = User.query.filter()

table = texttable.Texttable(max_width=120)
table.set_deco(texttable.Texttable().HEADER)
table.set_cols_dtype(["i", "t"])
table.set_cols_align(["l", "l"])
table.add_rows([["ID", "username"]])

for user in users.all():
table.add_row(
[
user.id,
user.name,
]
)

print(table.draw())


@users.command(name="create")
@with_appcontext
def create():
"""
Create a user.
"""
username = click.prompt("Username", type=str)
email = click.prompt("Email", type=str)
password = click.prompt("Password", type=str, hide_input=True, confirmation_prompt=True)
while True:
role = click.prompt("Role [admin/user]", type=str)
if role == "admin" or role == "user":
break

if click.confirm("Do you want to continue ?"):
role = Role.query.filter(Role.name == role).first()
if not role:
raise click.UsageError("Roles not present in database")
u = user_datastore.create_user(name=username, email=email, password=hash_password(password), roles=[role])

db.session.commit()

if FSConfirmable.requires_confirmation(u):
with current_app.app_context():
FSConfirmable.send_confirmation_instructions(u)
print("Look at your emails for validation instructions.")


@users.command(name="confirm")
@click.option("--username", prompt=True, help="Username")
@with_appcontext
def confirm(username):
"""
Force activation of an user.
"""
u = User.query.filter(User.name == username).first()
if not u:
print(f"Cannot find user with username '{username}'")
exit(1)

u.confirmed_at = datetime.datetime.now()
db.session.commit()

print("User confirmed at: ", u.confirmed_at)

+ 141
- 0
api/config/config.py View File

@@ -0,0 +1,141 @@
import os

# .i;;;;i.
# iYcviii;vXY:
# .YXi .i1c.
# .YC. . in7.
# .vc. ...... ;1c.
# i7, .. .;1;
# i7, .. ... .Y1i
# ,7v .6MMM@; .YX,
# .7;. ..IMMMMMM1 :t7.
# .;Y. ;$MMMMMM9. :tc.
# vY. .. .nMMM@MMU. ;1v.
# i7i ... .#MM@M@C. .....:71i
# it: .... $MMM@9;.,i;;;i,;tti
# :t7. ..... 0MMMWv.,iii:::,,;St.
# .nC. ..... IMMMQ..,::::::,.,czX.
# .ct: ....... .ZMMMI..,:::::::,,:76Y.
# c2: ......,i..Y$M@t..:::::::,,..inZY
# vov ......:ii..c$MBc..,,,,,,,,,,..iI9i
# i9Y ......iii:..7@MA,..,,,,,,,,,....;AA:
# iIS. ......:ii::..;@MI....,............;Ez.
# .I9. ......:i::::...8M1..................C0z.
# .z9; ......:i::::,.. .i:...................zWX.
# vbv ......,i::::,,. ................. :AQY
# c6Y. .,...,::::,,..:t0@@QY. ................ :8bi
# :6S. ..,,...,:::,,,..EMMMMMMI. ............... .;bZ,
# :6o, .,,,,..:::,,,..i#MMMMMM#v................. YW2.
# .n8i ..,,,,,,,::,,,,.. tMMMMM@C:.................. .1Wn
# 7Uc. .:::,,,,,::,,,,.. i1t;,..................... .UEi
# 7C...::::::::::::,,,,.. .................... vSi.
# ;1;...,,::::::,......... .................. Yz:
# v97,......... .voC.
# izAotX7777777777777777777777777777777777777777Y7n92:
# .;CoIIIIIUAA666666699999ZZZZZZZZZZZZZZZZZZZZ6ov.
#
# !!! ATTENTION !!!
# DO NOT EDIT THIS FILE! THIS FILE CONTAINS THE DEFAULT VALUES FOR THE CON-
# FIGURATION! EDIT YOUR OWN CONFIG FILE (based on production.py or development.py or testing.py).
# AND DICTATE THE APP TO USE YOUR FILE WITH ENV VARIABLE:
# APP_SETTINGS='config.yourEnv.Config'
#
# Editing this file is done at your own risks, don't cry if doing that transforms your cat in an opossum.


def bool_env(var_name, default=False):
test_val = os.getenv(var_name, default)
if test_val in ("False", "false", "0", "no"):
return False
return bool(test_val)


class BaseConfig(object):
""" Base configuration, pls dont edit me """

# Debug and testing specific
TESTING = bool_env("TESTING", False)
DEBUG = bool_env("DEBUG", False)

@property
def TEMPLATES_AUTO_RELOAD(self):
return self.DEBUG

# Can users register
REGISTRATION_ENABLED = bool_env("REGISTRATION_ENABLED", False)

# Registration, same as upper
@property
def SECURITY_REGISTERABLE(self):
return self.REGISTRATION_ENABLED

# Secret key, you are supposed to generate one
# Ex: `openssl rand -hex 42`
SECRET_KEY = os.getenv("SECRET_KEY", None)
# Ex: `openssl rand -hex 5`
SECURITY_PASSWORD_SALT = os.getenv("SECURITY_PASSWORD_SALT", None)

# Database stuff
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI", "postgresql+psycopg2://postgres@localhost/omnomnomnom")
SQLALCHEMY_ECHO = bool_env("SQLALCHEMY_ECHO", False)
# Thoses two shouldn't be touched
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_RECORD_QUERIES = True

# Flask-Security stuff
# Should users confirm theire email address ?
SECURITY_CONFIRMABLE = bool_env("SECURITY_CONFIRMABLE", True)

# We have an alternative way
SECURITY_RECOVERABLE = False
# Don't change or you will break things
SECURITY_CHANGEABLE = True
# Same or I bite you
SECURITY_PASSWORD_HASH = "bcrypt"
SECURITY_SEND_REGISTER_EMAIL = True
SECURITY_SEND_PASSWORD_CHANGE_EMAIL = True
SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = True

# Backend default language
BABEL_DEFAULT_LOCALE = os.getenv("BABEL_DEFAULT_LOCALE", "en")
# Not sure this one has any effect...
BABEL_DEFAULT_TIMEZONE = os.getenv("BABEL_DEFAULT_TIMEZONE", "UTC")

# If using sentry
SENTRY_DSN = os.getenv("SENTRY_DSN", None)

# Broker setup for Celery, same redis base for both
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/0")

# ActivityPub stuff
AP_ENABLED = bool_env("AP_ENABLED", False)

# Sources of that instance, should be your repos if forked
SOURCES_REPOSITORY_URL = os.getenv("SOURCES_REPOSITORY_URL", "https://github.com/dashie/omnopmnomnom")

# Mail setup
MAIL_SERVER = os.getenv("MAIL_SERVER", "localhost")
MAIL_PORT = os.getenv("MAIL_PORT", 25)
MAIL_USE_TLS = bool_env("MAIL_USE_TLS", False)
MAIL_USE_SSL = bool_env("MAIL_USE_SSL", False)
MAIL_USERNAME = os.getenv("MAIL_USERNAME", None)
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", None)

APP_HOST = "localhost"

@property
def MAIL_DEFAULT_SENDER(self):
return os.getenv("MAIL_DEFAULT_SENDER", f"postmaster@{self.APP_HOST}")

@property
def SECURITY_EMAIL_SENDER(self):
return os.getenv("MAIL_DEFAULT_SENDER", f"postmaster@{self.APP_HOST}")

SECURITY_EMAIL_SUBJECT_REGISTER = os.getenv("SECURITY_EMAIL_SUBJECT_REGISTER", "Welcome to omnomnomnom")

# Don't touch
SWAGGER_UI_DOC_EXPANSION = "list"

SECURITY_CLI_USERS_NAME = False
SECURITY_CLI_ROLES_NAME = False

+ 5
- 0
api/config/development.py View File

@@ -0,0 +1,5 @@
from .config import BaseConfig


class Config(BaseConfig):
DEBUG = True

+ 33
- 0
api/config/production_secret_sample.py View File

@@ -0,0 +1,33 @@
from .development import Config as BaseConfig


class Config(BaseConfig):
# See the Configuration documentation at:
# FIXME AHAH
# For all the config keys you can use

# Please generate me with: openssl rand -hex 42
SECRET_KEY = "fdsfsdfsdfsdfdsfsdfsdfsdfdsfsdfsd"
# Please generate me with: openssl rand -hex 5
SECURITY_PASSWORD_SALT = "omgponies"
# Set your DB URI
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://dashie@localhost/omnomnomnom"

APP_HOST = "miam.host.tld"

# If you are using Sentry, otherwise, set to None
SENTRY_DSN = None

# If you are using a modified instance, please set your own repository URL
SOURCES_REPOSITORY_URL = "https://github.com/dashie/omnomnomnom"

# Email settings
MAIL_SERVER = "localhost"
# MAIL_PORT = 25
# MAIL_USE_TLS = False
# MAIL_USE_SSL = False
# MAIL_USERNAME = None
# MAIL_PASSWORD = None

# CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0'
# CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/0'

+ 25
- 0
api/config/testing.py View File

@@ -0,0 +1,25 @@
from .config import BaseConfig


class Config(BaseConfig):
DEBUG = True
TESTING = True
MAIL_DEBUG = True
REGISTRATION_ENABLED = True
SECRET_KEY = "udf298euf02uf2f02f2uf0"
SECURITY_CONFIRMABLE = False
SECURITY_REGISTERABLE = True
SECURITY_RECOVERABLE = True
SECURITY_TRACKABLE = False
SECURITY_CHANGEABLE = True
SECURITY_PASSWORD_HASH = "bcrypt"
SECURITY_PASSWORD_SALT = "dsadsaasd"
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://postgres@localhost:5432/omnomnomnom_test"
SECURITY_SEND_REGISTER_EMAIL = False
SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False
SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = False
SECURITY_SEND_PASSWORD_RESET_EMAIL = False
# Bcrypt algorithm hashing rounds (reduced for testing purposes only!)
BCRYPT_LOG_ROUNDS = 4
CELERY_BROKER_URL = "redis://redis:6379/0"
CELERY_RESULT_BACKEND = "redis://redis:6379/0"

+ 1
- 0
api/migrations/README View File

@@ -0,0 +1 @@
Generic single-database configuration.

+ 45
- 0
api/migrations/alembic.ini View File

@@ -0,0 +1,45 @@
# A generic, single database configuration.

[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false


# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

+ 96
- 0
api/migrations/env.py View File

@@ -0,0 +1,96 @@
from __future__ import with_statement

import logging
from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option(
'sqlalchemy.url', current_app.config.get(
'SQLALCHEMY_DATABASE_URI').replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


def run_migrations_offline():
"""Run migrations in 'offline' mode.

This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.

Calls to context.execute() here emit the given string to the
script output.

"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
"""Run migrations in 'online' mode.

In this scenario we need to create an Engine
and associate a connection with the context.

"""

# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')

connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)

with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

+ 24
- 0
api/migrations/script.py.mako View File

@@ -0,0 +1,24 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from alembic import op # noqa: E402
import sqlalchemy as sa # noqa: E402
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade():
${upgrades if upgrades else "pass"}


def downgrade():
${downgrades if downgrades else "pass"}

+ 69
- 0
api/migrations/versions/000_77dc08b8493f_.py View File

@@ -0,0 +1,69 @@
"""Initial migration

Revision ID: 77dc08b8493f
Revises:
Create Date: 2020-08-09 11:20:39.008541

"""
from alembic import op # noqa: E402
import sqlalchemy as sa # noqa: E402
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '77dc08b8493f'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('role',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('password', sa.String(length=255), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('fs_uniquifier', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('locale', sa.String(length=5), nullable=True),
sa.Column('flake_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('slug', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('logging',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('level', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('roles_users',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('roles_users')
op.drop_table('logging')
op.drop_table('user')
op.drop_table('role')
# ### end Alembic commands ###

+ 117
- 0
api/models.py View File

@@ -0,0 +1,117 @@
import datetime

from flask import current_app
from flask_security import SQLAlchemyUserDatastore, UserMixin, RoleMixin
from flask_security.utils import verify_password
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.ext.hybrid import Comparator
from sqlalchemy.sql import func
from sqlalchemy_searchable import make_searchable
from sqlalchemy.dialects.postgresql import UUID
import uuid
from sqlalchemy import event


db = SQLAlchemy()
make_searchable(db.metadata)

# #### Base ####


class CaseInsensitiveComparator(Comparator):
def __eq__(self, other):
return func.lower(self.__clause_element__()) == func.lower(other)


# #### User ####

roles_users = db.Table(
"roles_users",
db.Column("user_id", db.Integer(), db.ForeignKey("user.id")),
db.Column("role_id", db.Integer(), db.ForeignKey("role.id")),
)


class Role(db.Model, RoleMixin):
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True, nullable=False, info={"label": "Name"})
description = db.Column(db.String(255), info={"label": "Description"})

__mapper_args__ = {"order_by": name}


class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=True, info={"label": "Email"})
name = db.Column(db.String(255), nullable=False, info={"label": "Username"})
password = db.Column(db.String(255), nullable=True, info={"label": "Password"})
active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime())
fs_uniquifier = db.Column(db.String(255))

created_at = db.Column(db.DateTime(timezone=False), default=datetime.datetime.utcnow)
updated_at = db.Column(
db.DateTime(timezone=False), default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)

locale = db.Column(db.String(5), default="en")

flake_id = db.Column(UUID(as_uuid=True), unique=False, nullable=True)

slug = db.Column(db.String(255), nullable=True)

# Relations

roles = db.relationship(
"Role", secondary=roles_users, backref=db.backref("users", lazy="dynamic"), cascade_backrefs=False
)
loggings = db.relationship("Logging", backref="user", lazy="dynamic", cascade="delete")

__mapper_args__ = {"order_by": name}

def is_admin(self):
admin_role = db.session.query(Role).filter(Role.name == "admin").one()
return admin_role in self.roles

def join_roles(self, string):
return string.join([i.description for i in self.roles])

@staticmethod
def generate_slug(target, value, oldvalue, initiator):
if value and (not target.slug or value != oldvalue):
target.slug = value

def __repr__(self):
return f"<User(id='{self.id}', name='{self.name}')>"

def check_password(self, password):
return verify_password(password, self.password)


event.listen(User.name, "set", User.generate_slug, retval=False)


@event.listens_for(User, "after_insert")
def generate_user_flakeid(mapper, connection, target):
if not target.flake_id:
flake_id = uuid.UUID(int=current_app.flake_id.get())
connection.execute(User.__table__.update().where(User.__table__.c.id == target.id).values(flake_id=flake_id))


user_datastore = SQLAlchemyUserDatastore(db, User, Role)


class Logging(db.Model):
__tablename__ = "logging"

id = db.Column(db.Integer, primary_key=True)
category = db.Column(db.String(255), nullable=False, default="General")
level = db.Column(db.String(255), nullable=False, default="INFO")
message = db.Column(db.Text, nullable=False)
timestamp = db.Column(
db.DateTime(timezone=False), default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)

user_id = db.Column(db.Integer(), db.ForeignKey("user.id"), nullable=True)

__mapper_args__ = {"order_by": timestamp.desc()}

+ 13
- 0
api/pyproject.toml View File

@@ -0,0 +1,13 @@
[tool.black]
line-length = 120
exclude = '''
/(
\.git
| \.eggs
| \.idea
| \__pycache__
| venv
| front
)/
'''
skip-numeric-underscore-normalization = true

+ 21
- 0
api/requirements.txt View File

@@ -0,0 +1,21 @@
psycopg2-binary==2.8.4
texttable==1.6.2
Unidecode==1.1.1
flask==1.1.1
python-slugify==4.0.0
sqlalchemy==1.3.12
bcrypt==3.1.7
celery==4.4.0
flask_mail==0.9.1
flask_migrate==2.5.2
git+https://github.com/maxcountryman/flask-uploads#egg=flask_uploads
sqlalchemy_searchable==1.1.0
redis==3.3.11
flask-accept==0.0.6
PyQRCode==1.2.1
flask-cors>=3.0.8
cachetools==4.0.0
flasgger==0.9.5
waitress==1.4.3
Flask-SQLAlchemy==2.4.1
git+https://github.com/jwag956/flask-security#egg=Flask-Security-too

+ 26
- 0
api/setup.cfg View File

@@ -0,0 +1,26 @@
[aliases]
test=pytest

[tool:pytest]
addopts = --verbose --cov=. --cov-report=xml:test-reports/coverage.xml --junitxml=test-reports/junit.xml
python_files = tests/*.py
junit_family=legacy

[flake8]
max-line-length = 120
ignore = E501,W504,W503
exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,venv,.eggs

[coverage:run]
omit =
tests/*
.eggs/*
config.py
configtest.py
**/*__init__.py
venv/*
front/*

[isort]
line_length=120
force_single_line=false

+ 31
- 0
api/setup.py View File

@@ -0,0 +1,31 @@
from setuptools import setup

setup(
name="omnomnomnom",
version="0.1",
license="AGPL",
python_requires=">=3.6",
long_description=open("../README.md").read(),
url="http://dev.sigpipe.me/dashie/omnomnomnom",
author="Dashie",
author_email="dashie@sigpipe.me",
install_requires=[
"Flask==1.1.1",
"SQLAlchemy==1.3.12",
"SQLAlchemy-Searchable==1.1.0",
"SQLAlchemy-Utils==0.36.1",
"Flask-Mail==0.9.1",
"Flask-Migrate==2.4.0",
"bcrypt==3.1.7",
"psycopg2-binary==2.8.4",
"unidecode==1.1.1",
"Flask_Babelex==0.9.4",
"texttable==1.6.1",
"python-slugify==4.0.0",
"redis==3.3.11",
"celery==4.4.0",
"flask-accept==0.0.6",
],
setup_requires=["pytest-runner"],
tests_require=["pytest==5.3.2", "pytest-cov==2.8.1"],
)

+ 0
- 0
api/utils/__init__.py View File


+ 82
- 0
api/utils/flake_id.py View File

@@ -0,0 +1,82 @@
import time
import threading
import os
import random
import binascii
import warnings

"""
k-ordered ID generation thingy
adapted from https://git.pleroma.social/pleroma/flake_id
"""

# Hack for python <=3.6; time_ns is >=3.7
if not hasattr(time, "time_ns"):
time.time_ns = lambda: int(time.time() * 1e9)


# The FlakeId.get() should return a 128bit integer
# Which should be able to be fed to uuid.UUID(int=FlakeId.get())


class FlakeId(object):
def __init__(self):
self.state_node = self.worker_id()
self.state_time = 0
self.state_sq = 0

def worker_id(self):
"""Worker ID generated from PID, ThreadID and 16bit random thing"""
random_bits = random.getrandbits(16)
# more cursed random things
pid = os.getpid()
tid = threading.get_ident()
ptid = f"{pid}{random_bits}{tid}"
return binascii.crc32(ptid.encode("utf-8"))

def time(self):
"""some length cursed timestamp"""
# docs don't seem to guarantee this to be 64bits so here's a bitmask
return time.time_ns() & 0xFFFF_FFFF_FFFF_FFFF

def gen_flakeid(self):
fid_time = format(self.state_time, "064b") # 64bit
fid_node = format(self.state_node, "048b") # 48bit
fid_seq = format(self.state_sq, "016b") # 16bit
# return a big 128bit binary string
return f"{fid_time}{fid_node}{fid_seq}"

def get(self):
"""Return a Flake ID"""
# Increment sequence on call
self.state_sq = (self.state_sq + 1) & 0xFFFF
# Get time
self.state_time = self.time()
# Get a Flake ID
flake_id = self.gen_flakeid()
return int(flake_id, 2)


def gen_flakeid():
warnings.warn("Use the class FlakeId.get() instead", DeprecationWarning, stacklevel=2)
# (64 bits) timestamp in ns
t = time.time_ns()

# (47 bits + 1 extra) "worker id", rand
# https://stackoverflow.com/questions/17125237/getrandbits-does-not-produce-constant-length-numbers
# computers are bad
w = random.getrandbits(47) + (1 << 47)

pid = os.getpid()
tid = threading.get_ident()
ptid = f"{pid}{tid}"

# (16b) sequence from PID+threadID
# might be possible to throw in the
# object id of the flask request
# 32bit crc
s = binascii.crc32(ptid.encode("utf-8"))
# butcher it
s = s & 0xFFFF

return int(f"{t}{w}{s}")

+ 112
- 0
api/utils/various.py View File

@@ -0,0 +1,112 @@
import datetime
import hashlib
from os.path import splitext
import random
import string

from flask_security import current_user

from models import db, Role, Logging


class InvalidUsage(Exception):
status_code = 400

def __init__(self, message, status_code=None, payload=None):
Exception.__init__(self)
self.message = message
if status_code is not None:
self.status_code = status_code
self.payload = payload

def to_dict(self):
rv = dict(self.payload or ())
rv["message"] = self.message
rv["status"] = "error"
rv["code"] = self.status_code
return rv


def is_admin():
adm = Role.query.filter(Role.name == "admin").first()
if not current_user or not current_user.is_authenticated or not adm:
return False
if adm in current_user.roles:
return True
return False


def add_log(category, level, message):
if not category or not level or not message:
print("!! Fatal error in add_log() one of three variables not set")
print("[LOG][{0}][{1}] {2}".format(level.upper(), category, message))
a = Logging(category=category, level=level.upper(), message=message)
db.session.add(a)
db.session.commit()


def duration_human(seconds):
if seconds is None:
return "error"
seconds = float(seconds)
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
years, days = divmod(days, 365.242199)

minutes = int(minutes)
hours = int(hours)
days = int(days)
years = int(years)

if years > 0:
return "%d year" % years + "s" * (years != 1)
elif days > 0:
return "%d day" % days + "s" * (days != 1)
elif hours > 0:
return "%d hour" % hours + "s" * (hours != 1)
elif minutes > 0:
return "%d mn" % minutes + "s" * (minutes != 1)
else:
return "%.2f sec" % seconds + "s" * (seconds != 1)


def get_hashed_filename(filename):
f_n, f_e = splitext(filename)

fs_fname = hashlib.sha256()
hashed_format = "%s-%s" % (f_n, datetime.datetime.now())
fs_fname.update(hashed_format.encode("utf-8"))
fs_fname = fs_fname.hexdigest()

return fs_fname + f_e


def generate_random_token():
t = hashlib.sha256()

magic_sauce = "".join([random.choice(string.ascii_letters + string.digits) for n in range(250)])
magic_sauce += str(datetime.datetime.now())

t.update(magic_sauce.encode("utf-8"))
return t.hexdigest()[:250]


def join_url(start, end):
if end.startswith("http://") or end.startswith("https://"):
# alread a full URL, joining makes no sense
return end
if start.endswith("/") and end.startswith("/"):
return start + end[1:]

if not start.endswith("/") and not end.startswith("/"):
return start + "/" + end

return start + end


# Also present in models.py, thx circle import
def strip_end(text, suffix):
if not text.endswith(suffix):
return text
return text[: len(text) - len(suffix)]

+ 1
- 0
api/version.py View File

@@ -0,0 +1 @@
VERSION = "0.0.1"

+ 4
- 0
babel.cfg View File

@@ -0,0 +1,4 @@
[python: api/**.py]
[jinja2: api/**/templates/**.jinja2]
[jinja2: api/**/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

Loading…
Cancel
Save