Compare commits

..

7 Commits

Author SHA1 Message Date
Ferdinand Thiessen 9e8117e554 [plugins] Fix scheduler accessing database while unbound from session
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 18:45:01 +02:00
Ferdinand Thiessen 88a4dc24f2 [db] Fix automatic migration upgrade for plugins and core
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 18:44:21 +02:00
Ferdinand Thiessen 0698327ef5 [core][deps] Use sqlalchemy_utils instead of copy-paste code for merging references
This fixes issues when using SQLite

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 17:07:12 +02:00
Ferdinand Thiessen aa8f8f6e64 [core][plugin] Allow blueprints to be set on instance level
This ensures blueprints are read from the plugin instance
instead of the class, allowing custom routes to be added within the
`load()` function.

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 17:05:04 +02:00
Ferdinand Thiessen 6ad8cd1728 [cli] Users and roles can be now managed using the cli
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 17:04:22 +02:00
Ferdinand Thiessen e2254b71b0 [core][plugin] Unify plugin model and real plugins
Plugins are now extensions of the database model,
allowing plugins to access all their properties.

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 15:39:05 +02:00
Ferdinand Thiessen 973b4527df [core] UA parsing: Add backwards compatibility for platform names
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-25 14:55:49 +02:00
21 changed files with 510 additions and 502 deletions

View File

@ -1,8 +1,8 @@
"""Flaschengeist: Initial """Initial core db
Revision ID: 255b93b6beed Revision ID: 20482a003db8
Revises: Revises:
Create Date: 2022-02-23 14:33:02.851388 Create Date: 2022-08-25 15:13:34.900996
""" """
from alembic import op from alembic import op
@ -11,24 +11,17 @@ import flaschengeist
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = "255b93b6beed" revision = "20482a003db8"
down_revision = None down_revision = None
branch_labels = ("flaschengeist",) branch_labels = ("flaschengeist",)
depends_on = None depends_on = None
def upgrade(): def upgrade():
op.create_table( # ### commands auto generated by Alembic - please adjust! ###
"plugin_setting",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("plugin", sa.String(length=127), nullable=True),
sa.Column("name", sa.String(length=127), nullable=False),
sa.Column("value", sa.PickleType(protocol=4), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")),
)
op.create_table( op.create_table(
"image", "image",
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False), sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("mimetype", sa.String(length=127), nullable=False), sa.Column("mimetype", sa.String(length=127), nullable=False),
sa.Column("thumbnail", sa.String(length=255), nullable=True), sa.Column("thumbnail", sa.String(length=255), nullable=True),
@ -36,27 +29,37 @@ def upgrade():
sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), sa.PrimaryKeyConstraint("id", name=op.f("pk_image")),
) )
op.create_table( op.create_table(
"permission", "plugin",
sa.Column("name", sa.String(length=30), nullable=True), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("name", sa.String(length=127), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")), sa.Column("version", sa.String(length=30), nullable=False),
sa.UniqueConstraint("name", name=op.f("uq_permission_name")), sa.Column("enabled", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin")),
) )
op.create_table( op.create_table(
"role", "role",
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=True), sa.Column("name", sa.String(length=30), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")), sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
sa.UniqueConstraint("name", name=op.f("uq_role_name")), sa.UniqueConstraint("name", name=op.f("uq_role_name")),
) )
op.create_table( op.create_table(
"role_x_permission", "permission",
sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), sa.Column("name", sa.String(length=30), nullable=True),
sa.Column("permission_id", flaschengeist.models.Serial(), nullable=True), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint( sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=True),
["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission") sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_permission_plugin_plugin")),
), sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")), sa.UniqueConstraint("name", name=op.f("uq_permission_name")),
)
op.create_table(
"plugin_setting",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=True),
sa.Column("name", sa.String(length=127), nullable=False),
sa.Column("value", sa.PickleType(), nullable=True),
sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_plugin_setting_plugin_plugin")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")),
) )
op.create_table( op.create_table(
"user", "user",
@ -67,48 +70,58 @@ def upgrade():
sa.Column("deleted", sa.Boolean(), nullable=True), sa.Column("deleted", sa.Boolean(), nullable=True),
sa.Column("birthday", sa.Date(), nullable=True), sa.Column("birthday", sa.Date(), nullable=True),
sa.Column("mail", sa.String(length=60), nullable=True), sa.Column("mail", sa.String(length=60), nullable=True),
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("avatar", flaschengeist.models.Serial(), nullable=True), sa.Column("avatar", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")), sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), sa.PrimaryKeyConstraint("id", name=op.f("pk_user")),
sa.UniqueConstraint("userid", name=op.f("uq_user_userid")), sa.UniqueConstraint("userid", name=op.f("uq_user_userid")),
) )
op.create_table( op.create_table(
"notification", "notification",
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("plugin", sa.String(length=127), nullable=False),
sa.Column("text", sa.Text(), nullable=True), sa.Column("text", sa.Text(), nullable=True),
sa.Column("data", sa.PickleType(), nullable=True), sa.Column("data", sa.PickleType(), nullable=True),
sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False), sa.Column("time", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("user_id", flaschengeist.models.Serial(), nullable=False), sa.Column("user", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_notification_user_id_user")), sa.Column("plugin", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["plugin"], ["plugin.id"], name=op.f("fk_notification_plugin_plugin")),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_notification_user_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")), sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")),
) )
op.create_table( op.create_table(
"password_reset", "password_reset",
sa.Column("user", flaschengeist.models.Serial(), nullable=False), sa.Column("user", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("token", sa.String(length=32), nullable=True), sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")), sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")),
sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")), sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")),
) )
op.create_table(
"role_x_permission",
sa.Column("role_id", flaschengeist.database.types.Serial(), nullable=True),
sa.Column("permission_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(
["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission")
),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")),
)
op.create_table( op.create_table(
"session", "session",
sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("token", sa.String(length=32), nullable=True), sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("lifetime", sa.Integer(), nullable=True), sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column("browser", sa.String(length=30), nullable=True), sa.Column("browser", sa.String(length=127), nullable=True),
sa.Column("platform", sa.String(length=30), nullable=True), sa.Column("platform", sa.String(length=64), nullable=True),
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")), sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_session")), sa.PrimaryKeyConstraint("id", name=op.f("pk_session")),
sa.UniqueConstraint("token", name=op.f("uq_session_token")), sa.UniqueConstraint("token", name=op.f("uq_session_token")),
) )
op.create_table( op.create_table(
"user_attribute", "user_attribute",
sa.Column("id", flaschengeist.models.Serial(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user", flaschengeist.models.Serial(), nullable=False), sa.Column("user", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=True), sa.Column("name", sa.String(length=30), nullable=True),
sa.Column("value", sa.PickleType(), nullable=True), sa.Column("value", sa.PickleType(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")), sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")),
@ -116,8 +129,8 @@ def upgrade():
) )
op.create_table( op.create_table(
"user_x_role", "user_x_role",
sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True),
sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), sa.Column("role_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")), sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")), sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")),
) )
@ -129,11 +142,13 @@ def downgrade():
op.drop_table("user_x_role") op.drop_table("user_x_role")
op.drop_table("user_attribute") op.drop_table("user_attribute")
op.drop_table("session") op.drop_table("session")
op.drop_table("role_x_permission")
op.drop_table("password_reset") op.drop_table("password_reset")
op.drop_table("notification") op.drop_table("notification")
op.drop_table("user") op.drop_table("user")
op.drop_table("role_x_permission") op.drop_table("plugin_setting")
op.drop_table("role")
op.drop_table("permission") op.drop_table("permission")
op.drop_table("role")
op.drop_table("plugin")
op.drop_table("image") op.drop_table("image")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -4,7 +4,7 @@ from flask import Flask
from flask_cors import CORS from flask_cors import CORS
from datetime import datetime, date from datetime import datetime, date
from flask.json import JSONEncoder, jsonify from flask.json import JSONEncoder, jsonify
from importlib.metadata import entry_points from sqlalchemy.exc import OperationalError
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from flaschengeist import logger from flaschengeist import logger
@ -37,19 +37,16 @@ class CustomJSONEncoder(JSONEncoder):
@Hook("plugins.loaded") @Hook("plugins.loaded")
def load_plugins(app: Flask): def load_plugins(app: Flask):
app.config["FG_PLUGINS"] = {} app.config["FG_PLUGINS"] = {}
all_plugins = entry_points(group="flaschengeist.plugins")
for plugin in pluginController.get_enabled_plugins(): for plugin in pluginController.get_enabled_plugins():
logger.debug(f"Searching for enabled plugin {plugin.name}") logger.debug(f"Searching for enabled plugin {plugin.name}")
entry_point = all_plugins.select(name=plugin.name)
if not entry_point:
logger.error(
f"Plugin {plugin.name} was enabled, but could not be found.",
exc_info=True,
)
continue
try: try:
loaded = entry_point[0].load()(entry_point[0]) # Load class
cls = plugin.entry_point.load()
plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin
# Custom loading tasks
plugin.load()
# Register blueprint
if hasattr(plugin, "blueprint") and plugin.blueprint is not None: if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint) app.register_blueprint(plugin.blueprint)
except: except:
@ -59,8 +56,7 @@ def load_plugins(app: Flask):
) )
continue continue
logger.info(f"Loaded plugin: {plugin.name}") logger.info(f"Loaded plugin: {plugin.name}")
app.config["FG_PLUGINS"][plugin.name] = loaded app.config["FG_PLUGINS"][plugin.name] = plugin
def create_app(test_config=None, cli=False): def create_app(test_config=None, cli=False):
app = Flask("flaschengeist") app = Flask("flaschengeist")
@ -79,7 +75,7 @@ def create_app(test_config=None, cli=False):
def __get_state(): def __get_state():
from . import __version__ as version from . import __version__ as version
return jsonify({"plugins": app.config["FG_PLUGINS"], "version": version}) return jsonify({"plugins": pluginController.get_loaded_plugins(), "version": version})
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_exception(e): def handle_exception(e):

View File

@ -3,8 +3,7 @@ from click.decorators import pass_context
from flask.cli import with_appcontext from flask.cli import with_appcontext
from flask_migrate import upgrade from flask_migrate import upgrade
from flaschengeist.alembic import alembic_migrations_path from flaschengeist.controller import pluginController
from flaschengeist.cli.plugin_cmd import install_plugin_command
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
@ -12,9 +11,13 @@ from flaschengeist.utils.hook import Hook
@with_appcontext @with_appcontext
@pass_context @pass_context
@Hook("plugins.installed") @Hook("plugins.installed")
def install(ctx): def install(ctx: click.Context):
plugins = pluginController.get_enabled_plugins()
# Install database # Install database
upgrade(alembic_migrations_path, revision="heads") upgrade(revision="flaschengeist@head")
# Install plugins # Install plugins
install_plugin_command(ctx, [], True) for plugin in plugins:
plugin = pluginController.install_plugin(plugin.name)
pluginController.enable_plugin(plugin.id)

View File

@ -1,12 +1,13 @@
import traceback
import click import click
from click.decorators import pass_context from click.decorators import pass_context
from flask import current_app from flask import current_app
from flask.cli import with_appcontext from flask.cli import with_appcontext
from importlib.metadata import EntryPoint, entry_points from importlib.metadata import EntryPoint, entry_points
from flaschengeist.database import db from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.controller import pluginController
from flaschengeist.models import Permission from werkzeug.exceptions import NotFound
@click.group() @click.group()
@ -14,33 +15,34 @@ def plugin():
pass pass
def install_plugin_command(ctx, plugin, all): @plugin.command()
"""Install one or more plugins""" @click.argument("plugin", nargs=-1, required=True, type=str)
if not all and len(plugin) == 0: @with_appcontext
ctx.fail("At least one plugin must be specified, or use `--all` flag.") @pass_context
def enable(ctx, plugin):
if all: """Enable one or more plugins"""
plugins = current_app.config["FG_PLUGINS"] for name in plugin:
else: click.echo(f"Enabling {name}{'.'*(20-len(name))}", nl=False)
try: try:
plugins = {plugin_name: current_app.config["FG_PLUGINS"][plugin_name] for plugin_name in plugin} pluginController.enable_plugin(name)
except KeyError as e:
ctx.fail(f"Invalid plugin name, could not find >{e.args[0]}<")
for name, plugin in plugins.items():
click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False)
# Install permissions
if plugin.permissions:
cur_perm = set(x.name for x in Permission.query.filter(Permission.name.in_(plugin.permissions)).all())
all_perm = set(plugin.permissions)
add = all_perm - cur_perm
if add:
db.session.bulk_save_objects([Permission(name=x) for x in all_perm])
db.session.commit()
# Custom installation steps
plugin.install()
click.secho(" ok", fg="green") click.secho(" ok", fg="green")
except NotFound:
click.secho(" not installed / not found", fg="red")
@plugin.command()
@click.argument("plugin", nargs=-1, required=True, type=str)
@with_appcontext
@pass_context
def disable(ctx, plugin):
"""Disable one or more plugins"""
for name in plugin:
click.echo(f"Disabling {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.disable_plugin(name)
click.secho(" ok", fg="green")
except NotFound:
click.secho(" not installed / not found", fg="red")
@plugin.command() @plugin.command()
@ -48,26 +50,45 @@ def install_plugin_command(ctx, plugin, all):
@click.option("--all", help="Install all enabled plugins", is_flag=True) @click.option("--all", help="Install all enabled plugins", is_flag=True)
@with_appcontext @with_appcontext
@pass_context @pass_context
def install(ctx, plugin, all): def install(ctx: click.Context, plugin, all):
"""Install one or more plugins""" """Install one or more plugins"""
return install_plugin_command(ctx, plugin, all) all_plugins = entry_points(group="flaschengeist.plugins")
if all:
plugins = [ep.name for ep in all_plugins]
elif len(plugin) > 0:
plugins = plugin
for name in plugin:
if not all_plugins.select(name=name):
ctx.fail(f"Invalid plugin name, could not find >{name}<")
else:
ctx.fail("At least one plugin must be specified, or use `--all` flag.")
for name in plugins:
click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.install_plugin(name)
except Exception as e:
click.secho(" failed", fg="red")
if logger.getEffectiveLevel() > 10:
ctx.fail(f"[{e.__class__.__name__}] {e}")
else:
ctx.fail(traceback.format_exc())
else:
click.secho(" ok", fg="green")
@plugin.command() @plugin.command()
@click.argument("plugin", nargs=-1, type=str) @click.argument("plugin", nargs=-1, required=True, type=str)
@with_appcontext @with_appcontext
@pass_context @pass_context
def uninstall(ctx: click.Context, plugin): def uninstall(ctx: click.Context, plugin):
"""Uninstall one or more plugins""" """Uninstall one or more plugins"""
if len(plugin) == 0: plugins = {plg.name: plg for plg in pluginController.get_installed_plugins() if plg.name in plugin}
ctx.fail("At least one plugin must be specified")
try: try:
plugins = {plugin_name: current_app.config["FG_PLUGINS"][plugin_name] for plugin_name in plugin} for name in plugin:
except KeyError as e: pluginController.disable_plugin(plugins[name])
ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<")
if ( if (
click.prompt( click.prompt(
"You are going to uninstall:\n\n" "You are going to uninstall:\n\n"
@ -80,10 +101,11 @@ def uninstall(ctx: click.Context, plugin):
!= "y" != "y"
): ):
ctx.exit() ctx.exit()
for name, plugin in plugins.items():
click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False) click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False)
plugin.uninstall() pluginController.uninstall_plugin(plugins[name])
click.secho(" ok", fg="green") click.secho(" ok", fg="green")
except KeyError:
ctx.fail(f"Invalid plugin ID, could not find >{name}<")
@plugin.command() @plugin.command()
@ -97,21 +119,27 @@ def ls(enabled, no_header):
return p.version return p.version
plugins = entry_points(group="flaschengeist.plugins") plugins = entry_points(group="flaschengeist.plugins")
enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [ installed_plugins = {plg.name: plg for plg in pluginController.get_installed_plugins()}
config["FLASCHENGEIST"]["auth"]
]
loaded_plugins = current_app.config["FG_PLUGINS"].keys() loaded_plugins = current_app.config["FG_PLUGINS"].keys()
if not no_header: if not no_header:
print(f"{' '*13}{'name': <20}|{'version': >10}") print(f"{' '*13}{'name': <20}| version | {' ' * 8} state")
print("-" * 46) print("-" * 63)
for plugin in plugins: for plugin in plugins:
if enabled and plugin.name not in enabled_plugins: is_installed = plugin.name in installed_plugins.keys()
is_enabled = is_installed and installed_plugins[plugin.name].enabled
if enabled and is_enabled:
continue continue
print( print(f"{plugin.name: <33}|{plugin_version(plugin): >12} | ", end="")
f"{plugin.name: <33}|{plugin_version(plugin): >12}" if is_enabled:
f"{click.style(' (enabled)', fg='green') if plugin.name in enabled_plugins else ' (disabled)'}" if plugin.name in loaded_plugins:
) print(click.style(" enabled", fg="green"))
else:
for plugin in [value for value in enabled_plugins if value not in loaded_plugins]: print(click.style("(failed to load)", fg="red"))
print(f"{plugin: <33}|{' '*12}" f"{click.style(' (not found)', fg='red')}") elif is_installed:
print(click.style(" disabled", fg="yellow"))
else:
print("not installed")
for name, plugin in installed_plugins.items():
if plugin.enabled and name not in loaded_plugins:
print(f"{name: <33}|{'': >12} |" f"{click.style(' failed to load', fg='red')}")

View File

@ -3,84 +3,59 @@
Used by plugins for setting and notification functionality. Used by plugins for setting and notification functionality.
""" """
import sqlalchemy
from typing import Union from typing import Union
from flask import current_app from flask import current_app
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound, BadRequest
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from flask_migrate import upgrade as database_upgrade
from importlib.metadata import entry_points from importlib.metadata import entry_points
from flaschengeist import version as flaschengeist_version
from .. import logger from .. import logger
from ..database import db from ..database import db
from ..utils.hook import Hook from ..utils.hook import Hook
from ..models import Plugin, PluginSetting, Notification from ..plugins import Plugin, AuthPlugin
from ..models import Notification
def get_enabled_plugins(): __required_plugins = ["users", "roles", "scheduler", "auth"]
def get_authentication_provider():
return [plugin for plugin in get_loaded_plugins().values() if isinstance(plugin, AuthPlugin)]
def get_loaded_plugins(plugin_name: str = None):
"""Get loaded plugin(s)"""
plugins = current_app.config["FG_PLUGINS"]
if plugin_name is not None:
plugins = [plugins[plugin_name]]
return {name: db.session.merge(plugins[name], load=False) for name in plugins}
def get_installed_plugins() -> list[Plugin]:
"""Get all installed plugins"""
return Plugin.query.all()
def get_enabled_plugins() -> list[Plugin]:
"""Get all installed and enabled plugins"""
try: try:
enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all() enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all()
except OperationalError as e: except OperationalError as e:
class PluginStub:
def __init__(self, name) -> None:
self.name = name
self.version = "?"
logger.error("Could not connect to database or database not initialized! No plugins enabled!") logger.error("Could not connect to database or database not initialized! No plugins enabled!")
logger.debug("Can not query enabled plugins", exc_info=True) logger.debug("Can not query enabled plugins", exc_info=True)
# Fake load required plugins so the database can at least be installed
enabled_plugins = [ enabled_plugins = [
PluginStub("auth"), entry_points(group="flaschengeist.plugins", name=name)[0].load()(
PluginStub("roles"), name=name, enabled=True, installed_version=flaschengeist_version
PluginStub("users"), )
PluginStub("scheduler"), for name in __required_plugins
] ]
return enabled_plugins return enabled_plugins
def get_setting(plugin_id: str, name: str, **kwargs):
"""Get plugin setting from database
Args:
plugin_id: ID of the plugin
name: string identifying the setting
default: Default value
Returns:
Value stored in database (native python)
Raises:
`KeyError` if no such setting exists in the database
"""
try:
setting = PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one()
return setting.value
except sqlalchemy.orm.exc.NoResultFound:
if "default" in kwargs:
return kwargs["default"]
else:
raise KeyError
def set_setting(plugin_id: str, name: str, value):
"""Save setting in database
Args:
plugin_id: ID of the plugin
name: String identifying the setting
value: Value to be stored
"""
setting = (
PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one_or_none()
)
if setting is not None:
if value is None:
db.session.delete(setting)
else:
setting.value = value
else:
db.session.add(PluginSetting(plugin=plugin_id, name=name, value=value))
db.session.commit()
def notify(plugin_id: str, user, text: str, data=None): def notify(plugin_id: str, user, text: str, data=None):
"""Create a new notification for an user """Create a new notification for an user
@ -108,55 +83,72 @@ def install_plugin(plugin_name: str):
if not entry_point: if not entry_point:
raise NotFound raise NotFound
plugin = entry_point[0].load()(entry_point[0]) cls = entry_point[0].load()
entity = Plugin(name=plugin.name, version=plugin.version) plugin: Plugin = cls.query.filter(Plugin.name == plugin_name).one_or_none()
db.session.add(entity) if plugin is None:
plugin = cls(name=plugin_name, installed_version=entry_point[0].dist.version)
db.session.add(plugin)
db.session.commit() db.session.commit()
return entity # Custom installation steps
plugin.install()
# Check migrations
directory = entry_point[0].dist.locate_file("")
for loc in entry_point[0].module.split(".") + ["migrations"]:
directory /= loc
if directory.exists():
database_upgrade(revision=f"{plugin_name}@head")
return plugin
@Hook("plugin.uninstalled") @Hook("plugin.uninstalled")
def uninstall_plugin(plugin_id: Union[str, int]): def uninstall_plugin(plugin_id: Union[str, int, Plugin]):
plugin = disable_plugin(plugin_id) plugin = disable_plugin(plugin_id)
logger.debug(f"Uninstall plugin {plugin.name}") logger.debug(f"Uninstall plugin {plugin.name}")
plugin.uninstall()
entity = current_app.config["FG_PLUGINS"][plugin.name]
entity.uninstall()
del current_app.config["FG_PLUGINS"][plugin.name]
db.session.delete(plugin) db.session.delete(plugin)
db.session.commit() db.session.commit()
@Hook("plugins.enabled") @Hook("plugins.enabled")
def enable_plugin(plugin_id: Union[str, int]): def enable_plugin(plugin_id: Union[str, int]) -> Plugin:
logger.debug(f"Enabling plugin {plugin_id}") logger.debug(f"Enabling plugin {plugin_id}")
plugin: Plugin = Plugin.query plugin = Plugin.query
if isinstance(plugin_id, str): if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
if plugin is None: elif isinstance(plugin_id, int):
logger.debug("Plugin not installed, trying to install")
plugin = install_plugin(plugin_id)
else:
plugin = plugin.get(plugin_id) plugin = plugin.get(plugin_id)
else:
raise TypeError
if plugin is None: if plugin is None:
raise NotFound raise NotFound
plugin.enabled = True plugin.enabled = True
db.session.commit() db.session.commit()
plugin = plugin.entry_point.load().query.get(plugin.id)
current_app.config["FG_PLUGINS"][plugin.name] = plugin
return plugin return plugin
@Hook("plugins.disabled") @Hook("plugins.disabled")
def disable_plugin(plugin_id: Union[str, int]): def disable_plugin(plugin_id: Union[str, int, Plugin]):
logger.debug(f"Disabling plugin {plugin_id}") logger.debug(f"Disabling plugin {plugin_id}")
plugin: Plugin = Plugin.query plugin: Plugin = Plugin.query
if isinstance(plugin_id, str): if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none() plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
else: elif isinstance(plugin_id, int):
plugin = plugin.get(plugin_id) plugin = plugin.get(plugin_id)
elif isinstance(plugin_id, Plugin):
plugin = plugin_id
else:
raise TypeError
if plugin is None: if plugin is None:
raise NotFound raise NotFound
if plugin.name in __required_plugins:
raise BadRequest
plugin.enabled = False plugin.enabled = False
db.session.commit() db.session.commit()
if plugin.name in current_app.config["FG_PLUGINS"].keys():
del current_app.config["FG_PLUGINS"][plugin.name]
return plugin return plugin

View File

@ -13,15 +13,15 @@ lifetime = 1800
def __get_user_agent_platform(ua: str): def __get_user_agent_platform(ua: str):
if "Win" in ua: if "Win" in ua:
return "Windows" return "windows"
if "Mac" in ua: if "Mac" in ua:
return "Macintosh" return "macintosh"
if "Linux" in ua: if "Linux" in ua:
return "Linux" return "linux"
if "Android" in ua: if "Android" in ua:
return "Android" return "android"
if "like Mac" in ua: if "like Mac" in ua:
return "iOS" return "ios"
return "unknown" return "unknown"
@ -84,12 +84,12 @@ def validate_token(token, request_headers, permission):
raise Unauthorized raise Unauthorized
def create(user, user_agent=None) -> Session: def create(user, request_headers=None) -> Session:
"""Create a Session """Create a Session
Args: Args:
user: For which User is to create a Session user: For which User is to create a Session
user_agent: User agent to identify session request_headers: Headers to validate user agent of browser
Returns: Returns:
Session: A created Token for User Session: A created Token for User
@ -100,8 +100,10 @@ def create(user, user_agent=None) -> Session:
token=token_str, token=token_str,
user_=user, user_=user,
lifetime=lifetime, lifetime=lifetime,
browser=user_agent.browser, platform=request_headers.get("Sec-CH-UA-Platform", None)
platform=user_agent.platform, or __get_user_agent_platform(request_headers.get("User-Agent", "")),
browser=request_headers.get("Sec-CH-UA", None)
or __get_user_agent_browser(request_headers.get("User-Agent", "")),
) )
session.refresh() session.refresh()
db.session.add(session) db.session.add(session)

View File

@ -3,7 +3,7 @@ import secrets
from io import BytesIO from io import BytesIO
from sqlalchemy import exc from sqlalchemy import exc
from flask import current_app from sqlalchemy_utils import merge_references
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask.helpers import send_file from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden from werkzeug.exceptions import NotFound, BadRequest, Forbidden
@ -15,8 +15,8 @@ from ..models import Notification, User, Role
from ..models.user import _PasswordReset from ..models.user import _PasswordReset
from ..utils.hook import Hook from ..utils.hook import Hook
from ..utils.datetime import from_iso_format from ..utils.datetime import from_iso_format
from ..utils.foreign_keys import merge_references from ..controller import imageController, messageController, pluginController, sessionController
from ..controller import imageController, messageController, sessionController from ..plugins import AuthPlugin
def __active_users(): def __active_users():
@ -41,16 +41,32 @@ def _generate_password_reset(user):
return reset return reset
def get_provider(userid: str):
return [p for p in pluginController.get_authentication_provider() if p.user_exists(userid)][0]
@Hook
def update_user(user: User, backend: AuthPlugin):
"""Update user data from backend
This is seperate function to provide a hook"""
backend.update_user(user)
if not user.display_name:
user.display_name = "{} {}.".format(user.firstname, user.lastname[0])
db.session.commit()
def login_user(username, password): def login_user(username, password):
logger.info("login user {{ {} }}".format(username)) logger.info("login user {{ {} }}".format(username))
for provider in pluginController.get_authentication_provider():
user = find_user(username) uid = provider.login(username, password)
if isinstance(uid, str):
user = get_user(uid)
if not user: if not user:
logger.debug("User not found in Database.") logger.debug("User not found in Database.")
user = User(userid=username) user = User(userid=uid)
db.session.add(user) db.session.add(user)
if current_app.config["FG_AUTH_BACKEND"].login(user, password): update_user(user, provider)
update_user(user)
return user return user
return None return None
@ -84,14 +100,6 @@ def reset_password(token: str, password: str):
db.session.commit() db.session.commit()
@Hook
def update_user(user):
current_app.config["FG_AUTH_BACKEND"].update_user(user)
if not user.display_name:
user.display_name = "{} {}.".format(user.firstname, user.lastname[0])
db.session.commit()
def set_roles(user: User, roles: list[str], create=False): def set_roles(user: User, roles: list[str], create=False):
"""Set roles of user """Set roles of user
@ -115,7 +123,7 @@ def set_roles(user: User, roles: list[str], create=False):
user.roles_ = fetched user.roles_ = fetched
def modify_user(user, password, new_password=None): def modify_user(user: User, password: str, new_password: str = None):
"""Modify given user on the backend """Modify given user on the backend
Args: Args:
@ -127,7 +135,8 @@ def modify_user(user, password, new_password=None):
NotImplemented: If backend is not capable of this operation NotImplemented: If backend is not capable of this operation
BadRequest: Password is wrong or other logic issues BadRequest: Password is wrong or other logic issues
""" """
current_app.config["FG_AUTH_BACKEND"].modify_user(user, password, new_password) provider = get_provider(user.userid)
provider.modify_user(user, password, new_password)
if new_password: if new_password:
logger.debug(f"Password changed for user {user.userid}") logger.debug(f"Password changed for user {user.userid}")
@ -165,37 +174,13 @@ def get_user(uid, deleted=False) -> User:
return user return user
def find_user(uid_mail):
"""Finding an user by userid or mail in database or auth-backend
Args:
uid_mail: userid and or mail to search for
Returns:
User if found or None
"""
mail = uid_mail.split("@")
mail = len(mail) == 2 and len(mail[0]) > 0 and len(mail[1]) > 0
query = User.userid == uid_mail
if mail:
query |= User.mail == uid_mail
user = User.query.filter(query).one_or_none()
if user:
update_user(user)
else:
user = current_app.config["FG_AUTH_BACKEND"].find_user(uid_mail, uid_mail if mail else None)
if user:
if not user.display_name:
user.display_name = "{} {}.".format(user.firstname, user.lastname[0])
db.session.add(user)
db.session.commit()
return user
@Hook @Hook
def delete_user(user: User): def delete_user(user: User):
"""Delete given user""" """Delete given user"""
# First let the backend delete the user, as this might fail # First let the backend delete the user, as this might fail
current_app.config["FG_AUTH_BACKEND"].delete_user(user) provider = get_provider(user.userid)
provider.delete_user(user)
# Clear all easy relationships # Clear all easy relationships
user.avatar_ = None user.avatar_ = None
user._attributes.clear() user._attributes.clear()
@ -247,10 +232,14 @@ def register(data, passwd=None):
set_roles(user, roles) set_roles(user, roles)
password = passwd if passwd else secrets.token_urlsafe(16) password = passwd if passwd else secrets.token_urlsafe(16)
current_app.config["FG_AUTH_BACKEND"].create_user(user, password)
try: try:
provider = [p for p in pluginController.get_authentication_provider() if p.can_register()][0]
provider.create_user(user, password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
except IndexError as e:
logger.error("No authentication backend, allowing registering new users, found.")
raise e
except exc.IntegrityError: except exc.IntegrityError:
raise BadRequest("userid already in use") raise BadRequest("userid already in use")
@ -265,7 +254,7 @@ def register(data, passwd=None):
) )
messageController.send_message(messageController.Message(user, text, subject)) messageController.send_message(messageController.Message(user, text, subject))
find_user(user.userid) provider.update_user(user)
return user return user
@ -274,19 +263,20 @@ def load_avatar(user: User):
if user.avatar_ is not None: if user.avatar_ is not None:
return imageController.send_image(image=user.avatar_) return imageController.send_image(image=user.avatar_)
else: else:
avatar = current_app.config["FG_AUTH_BACKEND"].get_avatar(user) provider = get_provider(user.userid)
avatar = provider.get_avatar(user)
if len(avatar.binary) > 0: if len(avatar.binary) > 0:
return send_file(BytesIO(avatar.binary), avatar.mimetype) return send_file(BytesIO(avatar.binary), avatar.mimetype)
raise NotFound raise NotFound
def save_avatar(user, file): def save_avatar(user, file):
current_app.config["FG_AUTH_BACKEND"].set_avatar(user, file) get_provider(user.userid).set_avatar(user, file)
db.session.commit() db.session.commit()
def delete_avatar(user): def delete_avatar(user):
current_app.config["FG_AUTH_BACKEND"].delete_avatar(user) get_provider(user.userid).delete_avatar(user)
db.session.commit() db.session.commit()

View File

@ -39,11 +39,7 @@ def configure_alembic(config: Config):
migrations = [config.get_main_option("script_location") + "/migrations"] migrations = [config.get_main_option("script_location") + "/migrations"]
# Gather all migration paths # Gather all migration paths
all_plugins = entry_points(group="flaschengeist.plugins") for entry_point in entry_points(group="flaschengeist.plugins"):
for plugin in pluginController.get_enabled_plugins():
entry_point = all_plugins.select(name=plugin.name)
if not entry_point:
continue
try: try:
directory = entry_point.dist.locate_file("") directory = entry_point.dist.locate_file("")
for loc in entry_point.module.split(".") + ["migrations"]: for loc in entry_point.module.split(".") + ["migrations"]:
@ -52,7 +48,7 @@ def configure_alembic(config: Config):
logger.debug(f"Adding migration version path {directory}") logger.debug(f"Adding migration version path {directory}")
migrations.append(str(directory.resolve())) migrations.append(str(directory.resolve()))
except: except:
logger.warning(f"Could not load migrations of plugin {plugin.name} for database migration.") logger.warning(f"Could not load migrations of plugin {entry_point.name} for database migration.")
logger.debug("Plugin loading failed", exc_info=True) logger.debug("Plugin loading failed", exc_info=True)
# write back seperator (we changed it if neither seperator nor locations were specified) # write back seperator (we changed it if neither seperator nor locations were specified)

View File

@ -1,4 +1,4 @@
import sys from importlib import import_module
import datetime import datetime
from sqlalchemy import BigInteger, util from sqlalchemy import BigInteger, util
@ -12,12 +12,11 @@ class ModelSerializeMixin:
""" """
def __is_optional(self, param): def __is_optional(self, param):
if sys.version_info < (3, 8):
return False
import typing import typing
hint = typing.get_type_hints(self.__class__)[param] module = import_module("flaschengeist.models").__dict__
hint = typing.get_type_hints(self.__class__, globalns=module)[param]
if ( if (
typing.get_origin(hint) is typing.Union typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2 and len(typing.get_args(hint)) == 2

View File

@ -1,26 +1,72 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Any from typing import Any
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db from ..database import db
from ..database.types import Serial from ..database.types import Serial
class Plugin(db.Model):
__tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False)
version: str = db.Column(db.String(30), nullable=False)
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
settings_ = db.relationship("PluginSetting", cascade="all, delete")
permissions_ = db.relationship("Permission", cascade="all, delete")
class PluginSetting(db.Model): class PluginSetting(db.Model):
__tablename__ = "plugin_setting" __tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True) id = db.Column("id", Serial, primary_key=True)
plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
name: str = db.Column(db.String(127), nullable=False) name: str = db.Column(db.String(127), nullable=False)
value: Any = db.Column(db.PickleType(protocol=4)) value: Any = db.Column(db.PickleType(protocol=4))
class BasePlugin(db.Model):
__tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False)
"""Name of the plugin, loaded from distribution"""
installed_version: str = db.Column("version", db.String(30), nullable=False)
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
"""Enabled state of the plugin"""
permissions: list = db.relationship(
"Permission", cascade="all, delete, delete-orphan", back_populates="plugin_", lazy="select"
)
"""Optional list of custom permissions used by the plugin
A good style is to name the permissions with a prefix related to the plugin name,
to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*.
"""
__settings: dict[str, "PluginSetting"] = db.relationship(
"PluginSetting",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete, delete-orphan",
lazy="select",
)
def get_setting(self, name: str, **kwargs):
"""Get plugin setting
Args:
name: string identifying the setting
default: Default value
Returns:
Value stored in database (native python)
Raises:
`KeyError` if no such setting exists in the database
"""
try:
return self.__settings[name].value
except KeyError as e:
if "default" in kwargs:
return kwargs["default"]
raise e
def set_setting(self, name: str, value):
"""Save setting in database
Args:
name: String identifying the setting
value: Value to be stored
"""
if value is None and name in self.__settings.keys():
del self.__settings[name]
else:
setting = self.__settings.setdefault(name, PluginSetting(plugin_id=self.id, name=name, value=None))
setting.value = value

View File

@ -24,8 +24,9 @@ class Permission(db.Model, ModelSerializeMixin):
__tablename__ = "permission" __tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True) name: str = db.Column(db.String(30), unique=True)
_id = db.Column("id", Serial, primary_key=True) id_ = db.Column("id", Serial, primary_key=True)
_plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
plugin_ = db.relationship("Plugin", lazy="select", back_populates="permissions", enable_typechecks=False)
class Role(db.Model, ModelSerializeMixin): class Role(db.Model, ModelSerializeMixin):
@ -64,9 +65,7 @@ class User(db.Model, ModelSerializeMixin):
# Protected stuff for backend use only # Protected stuff for backend use only
id_ = db.Column("id", Serial, primary_key=True) id_ = db.Column("id", Serial, primary_key=True)
roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge")
sessions_: list[Session] = db.relationship( sessions_: list[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan")
"Session", back_populates="user_", cascade="all, delete, delete-orphan"
)
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan")

View File

@ -4,13 +4,13 @@
""" """
from typing import Optional from typing import Union
from importlib.metadata import Distribution, EntryPoint from importlib.metadata import entry_points
from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.exceptions import NotFound
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from flaschengeist.models import User from flaschengeist.models.plugin import BasePlugin
from flaschengeist.models.user import _Avatar from flaschengeist.models.user import _Avatar, Permission
from flaschengeist.utils.hook import HookBefore, HookAfter from flaschengeist.utils.hook import HookBefore, HookAfter
__all__ = [ __all__ = [
@ -20,7 +20,7 @@ __all__ = [
"before_role_updated", "before_role_updated",
"before_update_user", "before_update_user",
"after_role_updated", "after_role_updated",
"BasePlugin", "Plugin",
"AuthPlugin", "AuthPlugin",
] ]
@ -71,7 +71,7 @@ Passed args:
""" """
class BasePlugin: class Plugin(BasePlugin):
"""Base class for all Plugins """Base class for all Plugins
All plugins must derived from this class. All plugins must derived from this class.
@ -82,47 +82,30 @@ class BasePlugin:
- *models*: Your models, used for API export - *models*: Your models, used for API export
""" """
name: str
"""Name of the plugin, loaded from EntryPoint"""
version: str
"""Version of the plugin, loaded from Distribution"""
dist: Distribution
"""Distribution of this plugin"""
blueprint = None blueprint = None
"""Optional `flask.blueprint` if the plugin uses custom routes""" """Optional `flask.blueprint` if the plugin uses custom routes"""
permissions: list[str] = []
"""Optional list of custom permissions used by the plugin
A good style is to name the permissions with a prefix related to the plugin name,
to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*.
"""
models = None models = None
"""Optional module containing the SQLAlchemy models used by the plugin""" """Optional module containing the SQLAlchemy models used by the plugin"""
migrations: Optional[tuple[str, str]] = None @property
"""Optional identifiers of the migration versions def version(self) -> str:
"""Version of the plugin, loaded from Distribution"""
return self.dist.version
If custom database tables are used migrations must be provided and the @property
head and removal versions had to be defined, e.g. def dist(self):
"""Distribution of this plugin"""
return self.entry_point.dist
``` @property
migrations = ("head_hash", "removal_hash") def entry_point(self):
``` ep = entry_points(group="flaschengeist.plugins", name=self.name)
""" return ep[0]
def __init__(self, entry_point: EntryPoint): def load(self):
"""Constructor called by create_app """__init__ like function that is called when the plugin is initially loaded"""
Args: pass
entry_point: EntryPoint from which this plugin was loaded
"""
self.version = entry_point.dist.version
self.name = entry_point.name
self.dist = entry_point.dist
def install(self): def install(self):
"""Installation routine """Installation routine
@ -145,40 +128,6 @@ class BasePlugin:
""" """
pass pass
@property
def installed_version(self):
"""Installed version of the plugin"""
from ..controller import pluginController
self.__installed_version = pluginController.get_installed_version(self.name)
return self.__installed_version
def get_setting(self, name: str, **kwargs):
"""Get plugin setting from database
Args:
name: string identifying the setting
default: Default value
Returns:
Value stored in database (native python)
Raises:
`KeyError` if no such setting exists in the database
"""
from ..controller import pluginController
return pluginController.get_setting(self.name, name, **kwargs)
def set_setting(self, name: str, value):
"""Save setting in database
Args:
name: String identifying the setting
value: Value to be stored
"""
from ..controller import pluginController
return pluginController.set_setting(self.name, name, value)
def notify(self, user, text: str, data=None): def notify(self, user, text: str, data=None):
"""Create a new notification for an user """Create a new notification for an user
@ -203,40 +152,53 @@ class BasePlugin:
""" """
return {"version": self.version, "permissions": self.permissions} return {"version": self.version, "permissions": self.permissions}
def install_permissions(self, permissions: list[str]):
"""Helper for installing a list of strings as permissions
class AuthPlugin(BasePlugin): Args:
permissions: List of permissions to install
"""
cur_perm = set(x.name for x in self.permissions)
all_perm = set(permissions)
new_perms = all_perm - cur_perm
self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [
Permission(name=x, plugin_=self) for x in new_perms
]
class AuthPlugin(Plugin):
"""Base class for all authentification plugins """Base class for all authentification plugins
See also `BasePlugin` See also `Plugin`
""" """
def login(self, user, pw): def login(self, login_name, password) -> Union[bool, str]:
"""Login routine, MUST BE IMPLEMENTED! """Login routine, MUST BE IMPLEMENTED!
Args: Args:
user: User class containing at least the uid login_name: The name the user entered
pw: given password password: The password the user used to log in
Returns: Returns:
Must return False if not found or invalid credentials, True if success Must return False if not found or invalid credentials, otherwise the UID is returned
""" """
raise NotImplemented raise NotImplemented
def update_user(self, user): def update_user(self, user: "User"):
"""If backend is using external data, then update this user instance with external data """If backend is using external data, then update this user instance with external data
Args: Args:
user: User object user: User object
""" """
pass pass
def find_user(self, userid, mail=None): def user_exists(self, userid) -> bool:
"""Find an user by userid or mail """Check if user exists on this backend
Args: Args:
userid: Userid to search userid: Userid to search
mail: If set, mail to search
Returns: Returns:
None or User True or False
""" """
return None raise NotImplemented
def modify_user(self, user, password, new_password=None): def modify_user(self, user, password, new_password=None):
"""If backend is using (writeable) external data, then update the external database with the user provided. """If backend is using (writeable) external data, then update the external database with the user provided.
@ -247,11 +209,14 @@ class AuthPlugin(BasePlugin):
password: Password (some backends need the current password for changes) if None force edit (admin) password: Password (some backends need the current password for changes) if None force edit (admin)
new_password: If set a password change is requested new_password: If set a password change is requested
Raises: Raises:
NotImplemented: If backend does not support this feature (or no password change)
BadRequest: Logic error, e.g. password is wrong. BadRequest: Logic error, e.g. password is wrong.
Error: Other errors if backend went mad (are not handled and will result in a 500 error) Error: Other errors if backend went mad (are not handled and will result in a 500 error)
""" """
raise NotImplemented pass
def can_register(self):
"""Check if this backend allows to register new users"""
return False
def create_user(self, user, password): def create_user(self, user, password):
"""If backend is using (writeable) external data, then create a new user on the external database. """If backend is using (writeable) external data, then create a new user on the external database.
@ -261,7 +226,7 @@ class AuthPlugin(BasePlugin):
password: string password: string
""" """
raise MethodNotAllowed raise NotImplementedError
def delete_user(self, user): def delete_user(self, user):
"""If backend is using (writeable) external data, then delete the user from external database. """If backend is using (writeable) external data, then delete the user from external database.
@ -270,9 +235,9 @@ class AuthPlugin(BasePlugin):
user: User object user: User object
""" """
raise MethodNotAllowed raise NotImplementedError
def get_avatar(self, user): def get_avatar(self, user) -> _Avatar:
"""Retrieve avatar for given user (if supported by auth backend) """Retrieve avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects, Default behavior is to use native Image objects,

View File

@ -6,13 +6,13 @@ from flask import Blueprint, request, jsonify
from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.plugins import BasePlugin from flaschengeist.plugins import Plugin
from flaschengeist.utils.HTTP import no_content, created from flaschengeist.utils.HTTP import no_content, created
from flaschengeist.utils.decorators import login_required from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import sessionController, userController from flaschengeist.controller import sessionController, userController
class AuthRoutePlugin(BasePlugin): class AuthRoutePlugin(Plugin):
blueprint = Blueprint("auth", __name__) blueprint = Blueprint("auth", __name__)
@ -40,7 +40,7 @@ def login():
user = userController.login_user(userid, password) user = userController.login_user(userid, password)
if not user: if not user:
raise Unauthorized raise Unauthorized
session = sessionController.create(user, user_agent=request.user_agent) session = sessionController.create(user, request_headers=request.headers)
logger.debug(f"token is {session.token}") logger.debug(f"token is {session.token}")
logger.info(f"User {userid} logged in.") logger.info(f"User {userid} logged in.")

View File

@ -7,42 +7,25 @@ import os
import hashlib import hashlib
import binascii import binascii
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from flaschengeist.plugins import AuthPlugin, plugins_installed from flaschengeist.plugins import AuthPlugin
from flaschengeist.models import User, Role, Permission from flaschengeist.models import User, Role, Permission
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist import logger from flaschengeist import logger
class AuthPlain(AuthPlugin): class AuthPlain(AuthPlugin):
def install(self): def can_register(self):
plugins_installed(self.post_install) return True
def post_install(self, *args, **kwargs): def login(self, login_name, password):
if User.query.filter(User.deleted == False).count() == 0: users: list[User] = (
logger.info("Installing admin user") User.query.filter((User.userid == login_name) | (User.mail == login_name))
role = Role.query.filter(Role.name == "Superuser").first() .filter(User._attributes.any(name="password"))
if role is None: .all()
role = Role(name="Superuser", permissions=Permission.query.all())
admin = User(
userid="admin",
firstname="Admin",
lastname="Admin",
mail="",
roles_=[role],
) )
self.modify_user(admin, None, "admin") for user in users:
db.session.add(admin) if AuthPlain._verify_password(user.get_attribute("password"), password):
db.session.commit() return user.userid
logger.warning(
"New administrator user was added, please change the password or remove it before going into"
"production mode. Initial credentials:\n"
"name: admin\n"
"password: admin"
)
def login(self, user: User, password: str):
if user.has_attribute("password"):
return AuthPlain._verify_password(user.get_attribute("password"), password)
return False return False
def modify_user(self, user, password, new_password=None): def modify_user(self, user, password, new_password=None):
@ -51,6 +34,12 @@ class AuthPlain(AuthPlugin):
if new_password: if new_password:
user.set_attribute("password", AuthPlain._hash_password(new_password)) user.set_attribute("password", AuthPlain._hash_password(new_password))
def user_exists(self, userid) -> bool:
return (
db.session.query(User.id_).filter(User.userid == userid, User._attributes.any(name="password")).first()
is not None
)
def create_user(self, user, password): def create_user(self, user, password):
if not user.userid: if not user.userid:
raise BadRequest("userid is missing for new user") raise BadRequest("userid is missing for new user")
@ -68,7 +57,7 @@ class AuthPlain(AuthPlugin):
return (salt + pass_hash).decode("ascii") return (salt + pass_hash).decode("ascii")
@staticmethod @staticmethod
def _verify_password(stored_password, provided_password): def _verify_password(stored_password: str, provided_password: str):
salt = stored_password[:64] salt = stored_password[:64]
stored_password = stored_password[64:] stored_password = stored_password[64:]
pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000) pass_hash = hashlib.pbkdf2_hmac("sha3-512", provided_password.encode("utf-8"), salt.encode("ascii"), 100000)

View File

@ -4,13 +4,13 @@ from email.mime.multipart import MIMEMultipart
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.models import User from flaschengeist.models import User
from flaschengeist.plugins import BasePlugin from flaschengeist.plugins import Plugin
from flaschengeist.utils.hook import HookAfter from flaschengeist.utils.hook import HookAfter
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.controller.messageController import Message from flaschengeist.controller.messageController import Message
class MailMessagePlugin(BasePlugin): class MailMessagePlugin(Plugin):
def __init__(self, entry_point, config): def __init__(self, entry_point, config):
super().__init__(entry_point, config) super().__init__(entry_point, config)
self.server = config["SERVER"] self.server = config["SERVER"]

View File

@ -6,7 +6,7 @@ Provides routes used to configure roles and permissions of users / roles.
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flaschengeist.plugins import BasePlugin from flaschengeist.plugins import Plugin
from flaschengeist.controller import roleController from flaschengeist.controller import roleController
from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.decorators import login_required from flaschengeist.utils.decorators import login_required
@ -14,9 +14,11 @@ from flaschengeist.utils.decorators import login_required
from . import permissions from . import permissions
class RolesPlugin(BasePlugin): class RolesPlugin(Plugin):
blueprint = Blueprint("roles", __name__) blueprint = Blueprint("roles", __name__)
permissions = permissions.permissions
def install(self):
self.install_permissions(permissions.permissions)
@RolesPlugin.blueprint.route("/roles", methods=["GET"]) @RolesPlugin.blueprint.route("/roles", methods=["GET"])

View File

@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.plugins import BasePlugin from flaschengeist.plugins import Plugin
from flaschengeist.utils.HTTP import no_content from flaschengeist.utils.HTTP import no_content
@ -38,11 +38,10 @@ def scheduled(id: str, replace=False, **kwargs):
return real_decorator return real_decorator
class SchedulerPlugin(BasePlugin): class SchedulerPlugin(Plugin):
def __init__(self, entry_point): blueprint = Blueprint("scheduler", __name__)
super().__init__(entry_point)
self.blueprint = Blueprint(self.name, __name__)
def load(self):
def __view_func(): def __view_func():
self.run_tasks() self.run_tasks()
return no_content() return no_content()
@ -61,6 +60,9 @@ class SchedulerPlugin(BasePlugin):
self.blueprint.add_url_rule("/cron", view_func=__view_func) self.blueprint.add_url_rule("/cron", view_func=__view_func)
def run_tasks(self): def run_tasks(self):
from ..database import db
self = db.session.merge(self)
changed = False changed = False
now = datetime.now() now = datetime.now()
status = self.get_setting("status", default=dict()) status = self.get_setting("status", default=dict())

View File

@ -9,7 +9,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions from . import permissions
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.plugins import BasePlugin from flaschengeist.plugins import Plugin
from flaschengeist.models import User from flaschengeist.models import User
from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController from flaschengeist.controller import userController
@ -17,9 +17,11 @@ from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.datetime import from_iso_format
class UsersPlugin(BasePlugin): class UsersPlugin(Plugin):
blueprint = Blueprint("users", __name__) blueprint = Blueprint("users", __name__)
permissions = permissions.permissions
def install(self):
self.install_permissions(permissions.permissions)
@UsersPlugin.blueprint.route("/users", methods=["POST"]) @UsersPlugin.blueprint.route("/users", methods=["POST"])

View File

@ -1,6 +1,8 @@
import click import click
from flask.cli import with_appcontext from flask.cli import with_appcontext
from werkzeug.exceptions import BadRequest, Conflict, NotFound from werkzeug.exceptions import NotFound
from flaschengeist.database import db
from flaschengeist.controller import roleController, userController from flaschengeist.controller import roleController, userController
@ -28,23 +30,52 @@ def user(ctx, param, value):
@click.command() @click.command()
@click.option("--add-role", help="Add new role", type=str) @click.option("--create", help="Add new role", is_flag=True)
@click.option("--set-admin", help="Make a role an admin role, adding all permissions", type=str) @click.option("--delete", help="Delete role", is_flag=True)
@click.option("--add-user", help="Add new user interactivly", callback=user, is_flag=True, expose_value=False) @click.option("--set-admin", is_flag=True, help="Make a role an admin role, adding all permissions", type=str)
@click.argument("role", nargs=-1, required=True, type=str)
def role(create, delete, set_admin, role):
"""Manage roles"""
ctx = click.get_current_context()
if (create and delete) or (set_admin and delete):
ctx.fail("Do not mix --delete with --create or --set-admin")
for role_name in role:
if create:
r = roleController.create_role(role_name)
else:
r = roleController.get(role_name)
if delete:
roleController.delete(r)
if set_admin:
r.permissions = roleController.get_permissions()
db.session.commit()
@click.command()
@click.option("--add-role", help="Add a role to an user", type=str)
@click.option("--create", help="Create new user interactivly", callback=user, is_flag=True, expose_value=False)
@click.option("--delete", help="Delete a user", is_flag=True)
@click.argument("user", nargs=-1, type=str)
@with_appcontext @with_appcontext
def users(add_role, set_admin): def user(add_role, delete, user):
"""Manage users"""
from flaschengeist.database import db from flaschengeist.database import db
ctx = click.get_current_context() ctx = click.get_current_context()
try: try:
if add_role:
roleController.create_role(add_role)
if set_admin:
role = roleController.get(set_admin)
role.permissions = roleController.get_permissions()
db.session.commit()
if USER_KEY in ctx.meta: if USER_KEY in ctx.meta:
userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"]) userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"])
except (BadRequest, NotFound) as e: else:
ctx.fail(e.description) for uid in user:
user = userController.get_user(uid)
if delete:
userController.delete_user(user)
elif add_role:
role = roleController.get(add_role)
user.roles_.append(role)
db.session.commit()
except NotFound:
ctx.fail(f"User not found {uid}")

View File

@ -1,51 +0,0 @@
# Borrowed from https://github.com/kvesteri/sqlalchemy-utils
# Modifications see: https://github.com/kvesteri/sqlalchemy-utils/issues/561
# LICENSED under the BSD license, see upstream https://github.com/kvesteri/sqlalchemy-utils/blob/master/LICENSE
import sqlalchemy as sa
from sqlalchemy.orm import object_session
def get_foreign_key_values(fk, obj):
mapper = sa.inspect(obj.__class__)
return dict(
(
fk.constraint.columns.values()[index],
getattr(obj, element.column.key)
if hasattr(obj, element.column.key)
else getattr(obj, mapper.get_property_by_column(element.column).key),
)
for index, element in enumerate(fk.constraint.elements)
)
def get_referencing_foreign_keys(mixed):
tables = [mixed]
referencing_foreign_keys = set()
for table in mixed.metadata.tables.values():
if table not in tables:
for constraint in table.constraints:
if isinstance(constraint, sa.sql.schema.ForeignKeyConstraint):
for fk in constraint.elements:
if any(fk.references(t) for t in tables):
referencing_foreign_keys.add(fk)
return referencing_foreign_keys
def merge_references(from_, to, foreign_keys=None):
"""
Merge the references of an entity into another entity.
"""
if from_.__tablename__ != to.__tablename__:
raise TypeError("The tables of given arguments do not match.")
session = object_session(from_)
foreign_keys = get_referencing_foreign_keys(from_.__table__)
for fk in foreign_keys:
old_values = get_foreign_key_values(fk, from_)
new_values = get_foreign_key_values(fk, to)
session.query(from_.__mapper__).filter(*[k == old_values[k] for k in old_values]).update(
new_values, synchronize_session=False
)

View File

@ -28,6 +28,7 @@ install_requires =
flask_migrate>=3.1.0 flask_migrate>=3.1.0
flask_sqlalchemy>=2.5.1 flask_sqlalchemy>=2.5.1
sqlalchemy>=1.4.40 sqlalchemy>=1.4.40
sqlalchemy_utils>=0.38.3
toml toml
werkzeug>=2.2.2 werkzeug>=2.2.2
@ -47,7 +48,8 @@ console_scripts =
flaschengeist = flaschengeist.cli:main flaschengeist = flaschengeist.cli:main
flask.commands = flask.commands =
ldap = flaschengeist.plugins.auth_ldap.cli:ldap ldap = flaschengeist.plugins.auth_ldap.cli:ldap
users = flaschengeist.plugins.users.cli:users user = flaschengeist.plugins.users.cli:user
role = flaschengeist.plugins.users.cli:role
flaschengeist.plugins = flaschengeist.plugins =
# Authentication providers # Authentication providers
auth_plain = flaschengeist.plugins.auth_plain:AuthPlain auth_plain = flaschengeist.plugins.auth_plain:AuthPlain