Browse Source

Add initial API base

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

37
.gitignore

@ -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
.tmuxinator.yml

@ -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
.vscode/settings.json

@ -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
api/.dockerignore

@ -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
api/app.py

@ -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
api/commands/__init__.py

@ -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
api/commands/db_datas.py

@ -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
api/commands/roles.py

@ -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
api/commands/system.py

@ -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
api/commands/users.py

@ -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
api/config/config.py

@ -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
api/config/development.py

@ -0,0 +1,5 @@
from .config import BaseConfig
class Config(BaseConfig):
DEBUG = True

33
api/config/production_secret_sample.py

@ -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
api/config/testing.py

@ -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
api/migrations/README

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

45
api/migrations/alembic.ini

@ -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
api/migrations/env.py

@ -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
api/migrations/script.py.mako

@ -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
api/migrations/versions/000_77dc08b8493f_.py

@ -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
api/models.py

@ -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
api/pyproject.toml

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

21
api/requirements.txt

@ -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
api/setup.cfg

@ -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
api/setup.py

@ -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
api/utils/__init__.py

82
api/utils/flake_id.py

@ -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
api/utils/various.py

@ -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
api/version.py

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

4
babel.cfg

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