diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f2b53f9 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +{ + "name": "KBP-DevContainer", + + "dockerComposeFile": [ + "../docker-compose.base.yaml", + "../docker-compose.dev.yaml" + ], + + "service": "pythonapp", + "workspaceFolder": "/usr/opt/pythonapp", + + "shutdownAction": "stopCompose", + + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/opt/pythonapp/env/bin/python", + "python.linting.enabled": true + } + } + }, + + "remoteUser": "root" +} + diff --git a/.env b/.env new file mode 100644 index 0000000..e1cd840 --- /dev/null +++ b/.env @@ -0,0 +1,20 @@ +# Parameter für NGINX-Server +NGINX_VERSION=mainline-alpine +NGINX_HOST_PORT=443 +NGINX_SERVER_NAME=kbp_crm.mylocal.de +NGINX_STATIC_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/html +NGINX_CONFIG_TEMPLATE_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/configtemplates +NGINX_CERTS=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/NGINX_Server/certs +NGINX_CERT=wildcard.mylocal.crt +NGINX_CERT_KEY=wildcard.mylocal.key + +# Parameter for Python App-Server +PYTHON_VERSION=slim-bookworm +PYTHON_APP_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/root +PYTHON_DEV_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm +PYTHON_DB_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/data +# PYTHON_ENV_DIR=/home/schoschi/Entwicklung/Projekte_Runden/kbcrm/App_Server/env + +# Parameter für Dev-Mode (Development-Container für VS-Code) +NODE_VERSION=lts +NPM_VERSION=latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb63d26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +env +node_modules +tsout +kbp_crm*.js +App_Server/data/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..08d0eb6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,103 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "typescript", + "tsconfig": "Client/tsconfig.json", + "problemMatcher": [ + "$tsc" + ], + "group": "build", + "label": "Typescript: Compile", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + } + }, + { + "type": "shell", + "group": "build", + "label": "Rollup: bundle", + "command": "npx rollup --config rollup.config.mjs", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + }, + "options": { + "cwd": "${workspaceFolder}/Client" + } + }, + { + "label": "Build Complete", + "dependsOrder": "sequence", + "dependsOn": [ + "Typescript: Compile", + "Rollup: bundle", + "ShowTime" + ], + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "type": "shell", + "group": "none", + "label": "ShowTime", + "command": "date", + "presentation": { + "echo": false, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + } + }, + { + "type": "shell", + "group": "none", + "label": "Open auth.db", + "command": "C:/Daten-GGG/OneDrive/GGG-Workspace/Projekte/Sqlite3/sqlite3.exe auth.db", + "options": { + "cwd": "${workspaceFolder}/App_Server/data" + }, + "problemMatcher": [], + "presentation": { + "echo": false, + "reveal": "always", + "focus": true, + "panel": "new", + "showReuseMessage": true, + "clear": true + } + }, + { + "type": "shell", + "group": "none", + "label": "Open app.db", + "command": "C:/Daten-GGG/OneDrive/GGG-Workspace/Projekte/Sqlite3/sqlite3.exe kbp_crm.db", + "options": { + "cwd": "${workspaceFolder}/App_Server/data" + }, + "problemMatcher": [], + "presentation": { + "echo": false, + "reveal": "always", + "focus": true, + "panel": "new", + "showReuseMessage": true, + "clear": true + } + } + ] +} \ No newline at end of file diff --git a/App_Server/.dockerignore b/App_Server/.dockerignore new file mode 100644 index 0000000..9c7fbde --- /dev/null +++ b/App_Server/.dockerignore @@ -0,0 +1,4 @@ +*/node_modules +*/tsout +*/env +*/data \ No newline at end of file diff --git a/App_Server/Dockerfile b/App_Server/Dockerfile new file mode 100644 index 0000000..e1d0023 --- /dev/null +++ b/App_Server/Dockerfile @@ -0,0 +1,59 @@ +ARG PYTHON_VERSION +ARG NODE_VERSION +ARG NPM_VERSION +FROM python:${PYTHON_VERSION} AS prod +RUN ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime +RUN apt-get clean && apt-get update && apt-get install -y locales +RUN echo "de_DE.UTF-8 UTF-8" >> /etc/locale.gen +RUN locale-gen +# Das Verzeichnis /usr/opt/pythonapp/env wird als Python-Umgebung (venv) verwendet. +# Es wird bewusst außerhalb des Verzeichnisses /usr/opt/pythonapp/App_Server erstellt, +# um die Python-Umgebung von den Anwendungsdateien zu trennen und das Virtual Environment nicht auf dem Host zu zeigen. +RUN mkdir -p /usr/opt/pythonapp/App_Server/root /usr/opt/pythonapp/env /usr/var/pythonappdata +WORKDIR /usr/opt/pythonapp +RUN python -m venv --upgrade-deps /usr/opt/pythonapp/env +COPY root/requirements.txt /usr/opt/pythonapp/App_Server/root/requirements.txt +RUN /usr/opt/pythonapp/env/bin/pip install --no-cache-dir --upgrade pip -r /usr/opt/pythonapp/App_Server/root/requirements.txt +# COPY scripts/init.sh /init.sh +# RUN chmod 552 /init.sh + +# # ENTRYPOINT [ "/init.sh" ] +# ENTRYPOINT [ "sh", "/init.sh" ] +# CMD [] + +FROM prod AS dev +ARG NODE_VERSION +ARG NPM_VERSION + +# --- 2. Systemabhängigkeiten (für Python + Node.js) --- +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git \ + curl \ + procps \ + build-essential \ + libpq-dev \ + libfreetype6-dev \ + libjpeg62-turbo-dev \ + libxml2-dev \ + libxslt1-dev \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +# --- 3. Node.js + npm installieren --- + +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \ + apt-get install -y nodejs && \ + npm install -g npm@${NPM_VERSION} + +# --- 8. Arbeitsverzeichnis für TypeScript --- +# In dem Entwicklungsverzeichnis (PYTHON_DEV_DIR) existiert dies schon als Unterverzeichnis "Client", daher wird es hier nicht erstellt + +# --- 9. TypeScript + Rollup installieren --- +# Das Script inittypescriptdev.sh wird in der Entwicklungsumgebung ausgeführt, um die TypeScript-Umgebung zu initialisieren. +# Daher ist es nicht notwendig, es hier zu kopieren und auszuführen. +# COPY ./scripts/inittypescriptdev.sh /usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh +# RUN chmod +x /usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh +ENTRYPOINT ["sh", "/usr/opt/pythonapp/App_Server/scripts/inittypescriptdev.sh"] +CMD ["/usr/opt/pythonapp/env/bin/python", "root/runserver.py", "-D"] + diff --git a/App_Server/root/approot.py b/App_Server/root/approot.py new file mode 100644 index 0000000..e40516f --- /dev/null +++ b/App_Server/root/approot.py @@ -0,0 +1,45 @@ +from flask import Flask +from flask_login import LoginManager +from settings import Configuration +from db import dbauth +from db import dbapp +from auth.model.checkdb import checkupdates as checkupdatesauth +# from model.checkdb import checkupdates as checkupdatesappdb +from os import path + +login_manager = LoginManager() + + +def init_app(): + app = Flask(__name__) + + config: Configuration = Configuration("root/config.ini") + _projektname = config.app_projectname + + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname) + app.config["SQLALCHEMY_BINDS"] = { + "auth": "sqlite:////usr/var/pythonappdb/auth.db", + "appdb": "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname) + } + app.config["DEBUG"] = path.exists("root/debug") + app.config["PROJEKTNAME"] = config.app_projectname + app.config["APPVERSION"] = config.clientapp_version + app.config["SECRET_KEY"] = config.flask_secret + + # Initialize Plugins + login_manager.init_app(app) + dbauth.init_app(app) + #dbapp.init_app(app) + + with app.app_context(): + # Include our Routes + print("before auth.routes", flush=True) + from auth import routes + from routes import general + checkupdatesauth() + # checkupdatesappdb() + + # from maintenance import init_cli + + return app + diff --git a/App_Server/root/auth/model/__init__.py b/App_Server/root/auth/model/__init__.py new file mode 100644 index 0000000..8d04abd --- /dev/null +++ b/App_Server/root/auth/model/__init__.py @@ -0,0 +1,258 @@ +import sqlite3 +from typing import List +from sqlalchemy.engine import Engine +from sqlalchemy import ForeignKey, event +from sqlalchemy import Column, Integer, String, DateTime +import datetime, decimal +from db import dbauth + + +SYS_ADMIN = "SysAdmin" +APP_ADMIN = "AppAdmin" +APP_USER = "User" +APP_DOZENT = "Doz" + +BINDKEY = 'auth' +#c = Connection() + +def columns2dict(record, excludefields=[]): + if not isinstance(excludefields, list): + excludefields = [] + attribs = dict() + if hasattr(record, '__table__'): + if hasattr(record.__table__, 'columns'): + for col in record.__table__.columns: + if col.name in excludefields: + continue + value = getattr(record, col.name) + if isinstance(value, datetime.date): + value = value.isoformat() + if isinstance(value, decimal.Decimal): + value = "{0:.2f}".format(value) + attribs[col.name] = value + return attribs + +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + # Dieser Trigger darf nur bei einer Sqlite-Verbindung zünden. + # Bei einer VErbindugn zum MS SQL-Server darf er nicht ausgelöst werden + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + +class Systeminfo(dbauth.Model): + __tablename__ = 'systeminfo' + __bind_key__ = BINDKEY + id = Column(Integer, primary_key=True) + version = Column(String(20), nullable=False) + lastupdate = Column(DateTime, nullable=True) + initialinstall = Column(DateTime, nullable=True) + +# diese Tabelle enthält für alle Apps diejenigen Rollen, die in der App notwendig sind +# Da die Rollen nur einmal in der Tabelle "Role" definiert sind und mehrere Apps die gleichen +# Rollen haben können, handelt es sich im eine "many-to-many"-Relation, die in dieser +# Hilfstabelle verwaltet werden +apps_roles = dbauth.Table( + 'apps_roles', + Column('appid', Integer, ForeignKey('applications.id'), primary_key=True), + Column('roleid', Integer, ForeignKey('roles.id'), primary_key=True), + bind_key=BINDKEY +) + +# diese Tabelle enthält für alle Apps, in denen der User eine Rolle hat, die Rollen, +# die der User in der jeweiligen App hat. +# Da die Rollen nur einmal in der Tabelle "Role" definiert sind und verschiedene einem User +# zugeordnete Apps die gleichen Rollen haben können, handelt es sich im eine +# "many-to-many"-Relation, die in dieser Hilfstabelle verwaltet werden +userapps_roles = dbauth.Table( + 'userapps_roles', + Column('userappid', Integer, ForeignKey('userapps.id'), primary_key=True), + Column('roleid', Integer, ForeignKey('roles.id'), primary_key=True), + bind_key=BINDKEY +) + +class Role(dbauth.Model): + __tablename__ = 'roles' + __bind_key__ = BINDKEY + id = Column(Integer, primary_key=True) + roleid = Column(String(80), index=True, unique=True, nullable=False, default=None) + name = Column(String(80), nullable=False, default=None) + level = Column(Integer, default=0) + beschreibung = Column(String(800)) + + def to_dict(self): + if self.level is None: + self.level = 0 + return columns2dict(self) + +# Die Tabelle "UserApp" enthält für jeden User für jede App, in der der User eine Rolle hat, +# einen Datensatz. +class Userapp(dbauth.Model): + __tablename__ = 'userapps' + __bind_key__ = BINDKEY + id = Column(Integer, primary_key=True) + userid = Column(ForeignKey('users.id'), index=True) + appid = Column(ForeignKey('applications.id')) + userapp_roles = dbauth.relationship('Role', secondary=userapps_roles, lazy='subquery', order_by="asc(Role.roleid)") + application = dbauth.relationship('Application', uselist=False) + + def get_role(self, roleid: str) -> Role: + """ + Die Methode gibt die Rolle aus der userapp zurück, die mit der roleid (nicht id) angegeben wurde. + Gibt es keine Rolle mit der übergebenen roleid, wird 'None' zurückgegeben + - roleid: str + - die roleid (nicht id) der Rollue, die gesucht wird + """ + for role in self.userapp_roles: + if role.id == roleid: + return role + return None + + def to_dict(self, withrelations=True): + result = columns2dict(self) + result["userapp_roles"] = [] + if withrelations: + for userapprole in self.userapp_roles: + result["userapp_roles"].append(userapprole.to_dict()) + return result + +class User(dbauth.Model): + __tablename__ = 'users' + __bind_key__ = BINDKEY + id = Column(Integer, primary_key=True) + userid = Column(String(80), index=True, unique=True, nullable=False, default='') + username = Column(String(80), nullable=False) + password = Column(String(40), nullable=False) + email = Column(String(120)) + telefon = Column(String(800)) + # Jeder User kann mehrere "User-Apps" haben. Dies sind die Apps, in denen dem User Rollen + # zugewiesen wurden. Es handelt sich um eine "one-to-many"-Relation, da ein User zwar mehrere + # User-Apps haben kann, jede User-App aber genau einem User gehört + userapps = dbauth.relationship('Userapp') + + def get_userapp(self, appid:str) -> Userapp: + """ + Die Funktion liefert die userapp mit Hilfe der appid (nicht der id) zurück. + Wenn der User keine userapp für die Application hat, wird 'None' zurückgegeben + + - appid: string + - Der Kurzname der Application (nicht die id) + """ + for userapp in self.userapps: + app = userapp.application + if app.appid == appid: + return userapp + return None + + def get_userapp_by_id(self, id:int) -> Userapp: + """ + Die Funktion liefert die userapp mit Hilfe der id (nicht der appid) zurück + + - appid: int + - die interne Record-ID der Application (nicht die userid) + """ + for userapp in self.userapps: + app = userapp.application + if app.id == id: + return userapp + return None + + def get_userapproles(self, appid:str) -> List[Role]: + """ + Die Funktion liefert die roles zurück, die der User in der durch die appid (nicht id) bestimmten Application hat + + - appid: str + - Der Kurzname der Application + """ + userapp: Userapp = self.get_userapp(appid) + if userapp is not None: + return userapp.userapp_roles + else: + return [] + + def has_roles(self, roleids: List[str]) -> bool: + """ + Die Funktion liefert 'True' zurück, falls der User in irgendeiner Application eine der Rollen hat, deren roleids + übergeben wurden, ansonsten 'False' + + - roleids: List[str] + - eine Liste mit roleids (nicht ids) + """ + for userapp in self.userapps: + for role in userapp.userapp_roles: + if role.roleid in roleids: + return True + return False + + def is_SysAdmin(self) -> bool: + """ + die Methode liefert 'True' zurück, wenn der User in irgendeiner Anwendung SysAdmin ist + """ + return self.has_roles([SYS_ADMIN]) + + def __repr__(self): + return '' % self.username + + def to_dict(self, withrelations=False): + result = columns2dict(self, excludefields=['password']) + result['userapps'] = [] + if withrelations: + for userapp in self.userapps: + result['userapps'].append(userapp.to_dict()) + return result + + def to_relationaldict(self): + return self.to_dict(withrelations=True) + + def has_permission(self, appid:str, allowed_roles: List[str]): + """ + Gibt an, ob der User in einer bestimmten Anwendung eine der gewünschten Rollen hat + - appid: string + - Die appid (nicht id) der gewünschten Application + - allowed_roles: List[str] + - Eine Liste mit den roleids (nicht ids) der erlaubten Rollen + """ + userapp = self.get_userapp(appid) + if userapp is not None: + rolleerlaubt = any([role.roleid in allowed_roles for role in userapp.userapp_roles]) + if rolleerlaubt: + return True + return False + + + + # Die nachfolgenden Properties werden für flask_login benötigt + @property + def is_authenticated(self): + return True + + @property + def is_active(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + return self.userid + +class Application(dbauth.Model): + __tablename__ = 'applications' + __bind_key__ = BINDKEY + id = Column(Integer, primary_key=True) + appid = Column(String(80), index=True, unique=True, nullable=False, default=None) + name = Column(String(80), nullable=False, default=None) + minversion = Column(String(20)) + beschreibung = Column(String(800)) + roles = dbauth.relationship('Role', secondary=apps_roles, lazy='subquery', order_by="asc(Role.roleid)") + + def to_dict(self, withrelations=True): + result = columns2dict(self) + result['roles'] = [] + for role in self.roles: + result['roles'].append(role.to_dict()) + return result + +print("########## auth/model importiert") \ No newline at end of file diff --git a/App_Server/root/auth/model/applications.py b/App_Server/root/auth/model/applications.py new file mode 100644 index 0000000..72f5bc5 --- /dev/null +++ b/App_Server/root/auth/model/applications.py @@ -0,0 +1,73 @@ +from . import APP_ADMIN, SYS_ADMIN, User, Userapp, dbauth, Application, Role, apps_roles, userapps_roles +from .errors import AutorisationError, ApplicationError +from flask_login import current_user +from sqlalchemy import delete + +session = dbauth.session + +def get_applications(): + apps = [] + for app in Application.query: + # Die Application wird nur zurückgegeben, wenn der current-User SysAdmin ist oder wenn + # der current_user in der zurückzugebenden Application AppAdmin ist + if current_user.is_SysAdmin() or current_user.has_permission(app.appid, [APP_ADMIN]): + if not current_user.is_SysAdmin(): + for role in list(app.roles): + if role.roleid == SYS_ADMIN: + app.roles.remove(role) + apps.append(app) + return apps + +def updateapplication(data): + if not current_user.is_SysAdmin(): + raise AutorisationError(allowed_roles=[SYS_ADMIN], user_roles=current_user.get_userapproles(data["appid"])) + currentapp = None + if data["id"] == -1: + currentapp = Application() + currentapp.id = None + session.add(currentapp) + else: + currentapp = Application.query.get(data["id"]) + if currentapp is None: + raise Exception ("404") + currentapp.name = data["name"] + currentapp.appid = data["appid"] + currentapp.minversion = data["minversion"] + currentapp.beschreibung = data["beschreibung"] + session.commit() + +def deleteapplications(appids): + if not current_user.is_SysAdmin(): + raise AutorisationError(allowed_roles=[SYS_ADMIN], user_roles=None) + for appid in appids: + app: Application = Application.query.filter(Application.appid == appid).one() + if app is not None: + # Die Datensätze in "apps_roles" müssen gelöscht werden + app.roles.clear() + # UserApps löschen + q_userapps = dbauth.select(Userapp). where(Userapp.appid == app.id) + for userapp in dbauth.session.scalars(q_userapps): + userapp.userapp_roles.clear() + session.delete(userapp) + session.delete(app) + session.commit() + +def updateapplicationroles(approles): + # Änderungen an Applications darf grundsätzlich nur der SysAdmin machen + if not current_user.is_SysAdmin(): + raise AutorisationError(allowed_roles=[SYS_ADMIN], user_roles=None) + + for appidinfo in approles: + app: Application = Application.query.filter(Application.appid == appidinfo["appid"]).one() + if app is None: + raise ApplicationError(f"Die Application mit der Appid {appidinfo['appid']} existiert nicht", ) + for roleidinfo in appidinfo["roles"]: + role: Role = Role.query.filter(Role.roleid == roleidinfo["roleid"]).one() + if role in app.roles: + if not roleidinfo['isChecked']: + app.roles.remove(role) # hier kann das remove einfach so verwendet werden, da nicht über app.roles iteriert wird + # TODO: die Rolle muss auch aus allen userapps für alle User gelöscht werden + else: + if roleidinfo['isChecked']: + app.roles.append(role) + session.commit() \ No newline at end of file diff --git a/App_Server/root/auth/model/checkdb.py b/App_Server/root/auth/model/checkdb.py new file mode 100644 index 0000000..869767e --- /dev/null +++ b/App_Server/root/auth/model/checkdb.py @@ -0,0 +1,86 @@ +from datetime import datetime +from auth import model +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from datetime import datetime +from db import dbauth + +session = dbauth.session +BINDKEY = "auth" + +def _db_V01_00(): + # "bind_key=None" bedeutet hier, dass bei "create_all" nur die Models mit + # Default-Bind erzeugt werden sollen und nicht die anderen, insbesonder + # nicht die zum MS-SQL-SErver + dbauth.create_all(bind_key=BINDKEY) + systeminfo = model.Systeminfo() + systeminfo.id = 1 + systeminfo.version = "01.00" + systeminfo.lastupdate = datetime.now() + systeminfo.initialinstall = datetime.now() + session.add(systeminfo) + session.commit() + +def _db_V01_01(): + u = model.User() + u.id = 1 + u.userid = 'System.Admin' + u.username = 'Systemadministrator' + u.password = "geheim" + u.email = '' + session.add(u) + + sysmgr = model.Role() + sysmgr.id = 1 + sysmgr.name = "Systemadministrator" + sysmgr.roleid = "SysAdmin" + session.add(sysmgr) + + appmgr = model.Role() + appmgr.name = "Anwendungsadministrator" + appmgr.roleid = "AppAdmin" + session.add(appmgr) + + usermgr = model.Role() + usermgr.name = "Benutzerverwaltung" + usermgr.roleid = "UserMgr" + session.add(usermgr) + + application = model.Application() + application.id = 1 + application.appid = "Project01" + application.name = "Project01 (Name ändern)" + session.add(application) + + userapp = model.Userapp() + userapp.application = application + userapp.userapp_roles.append(sysmgr) + session.add(userapp) + u.userapps.append(userapp) + + _updatesysteminfo('01.01') + session.commit() + +def _updatesysteminfo(versionnr): + s: model.Systeminfo = model.Systeminfo.query.get(1) + s.version = versionnr + s.lastupdate = datetime.now() + return + +def checkupdates(): + systeminfo = None + curr_dbversion = '_db_V01_00' + try: + systeminfo: model.Systeminfo = model.Systeminfo.query.get(1) + curr_dbversion = "_db_V" + systeminfo.version.replace(".", "_") + except (OperationalError, AttributeError): + _db_V01_00() + + versionen = sorted(filter(lambda f: f.startswith("_db_V"),globals())) + + for v in versionen: + if v > curr_dbversion: + globals()[v]() # https://docs.python.org/3.6/library/functions.html?highlight=globals#globals + + print('########## Database updated') + diff --git a/App_Server/root/auth/model/errors.py b/App_Server/root/auth/model/errors.py new file mode 100644 index 0000000..d31110e --- /dev/null +++ b/App_Server/root/auth/model/errors.py @@ -0,0 +1,38 @@ + +class ApplicationError(Exception): + def __init__(self, *messages, htmlstatus=400): + self.htmlstatus = htmlstatus + Exception.__init__(self, *messages) + + @property + def error(self): + error = { + "status": self.htmlstatus, + "messages": list(self.args) + } + return error + +class AutorisationError(ApplicationError): + def __init__(self, allowed_roles=[], user_roles=[]): + self.allowed_roles = allowed_roles + self.user_roles = user_roles + + if user_roles is None: + userrolestxt = "NoSysAdmin" + else: + userrolestxt = ", ".join([role.name for role in self.user_roles]) + + ApplicationError.__init__(self, + "Sie haben nicht die passende Rolle für diese Funktion", + "Ihre Rollen sind: " + userrolestxt, + "Erforderliche Rollen sind: " + ", ".join(self.allowed_roles), htmlstatus=HtmlStatusCode.Unauthorized) + +class HtmlStatusCode(): + Bad_Request = 400 + Unauthorized = 401 + Payment_Required = 402 + Forbidden = 403 + Not_Found = 404 + Method_Not_Allowed = 405 + + diff --git a/App_Server/root/auth/model/roles.py b/App_Server/root/auth/model/roles.py new file mode 100644 index 0000000..9f1997a --- /dev/null +++ b/App_Server/root/auth/model/roles.py @@ -0,0 +1,50 @@ +from . import SYS_ADMIN, Application, Role, dbauth, User +from sqlalchemy.sql import collate +from flask_login import current_user +from .errors import AutorisationError + +session = dbauth.session + +def get_roles(): + roles = Role.query.order_by(collate(Role.roleid, 'NOCASE')).all() + return roles + +def updaterole(data): + if not current_user.is_SysAdmin(): + raise AutorisationError(allowed_roles=[SYS_ADMIN], user_roles=None) + currentrole = None + if data["id"] == -1: + currentrole = Role() + currentrole.id = None + session.add(currentrole) + else: + currentrole = Role.query.get(data["id"]) + if currentrole is None: + raise Exception ("404") + currentrole.level = data["level"] + currentrole.name = data["name"] + currentrole.roleid = data["roleid"] + currentrole.beschreibung = data["beschreibung"] + session.commit() + +def deleteroles(roleids): + if not current_user.is_SysAdmin(): + raise AutorisationError(allowed_roles=[SYS_ADMIN], user_roles=None) + for roleid in roleids: + role: Role = Role.query.filter(Role.roleid == roleid).one() + if role is not None: + appq = Application.query + for app in appq: + if role in app.roles: + app.roles.remove(role) # remove kann hier einfach so verwendet werden, da nicht über app.roles iteriert wird + userq = User.query + for user in userq: + for userapp in user.userapps: + if role in userapp.userapp_roles: + userapp.userapp_roles.remove(role) # remove kann hier einfach so verwendet werden, da nicht über userapp_roles iteriert wird + session.delete(role) + + session.commit() + + + diff --git a/App_Server/root/auth/model/users.py b/App_Server/root/auth/model/users.py new file mode 100644 index 0000000..838cf57 --- /dev/null +++ b/App_Server/root/auth/model/users.py @@ -0,0 +1,179 @@ + +from . import APP_ADMIN, SYS_ADMIN, Application, Role, User, dbauth, Userapp +from .errors import ApplicationError, AutorisationError +from flask_login import current_user +current_user: User + +session = dbauth.session + +def check_login(userid: str, password: str): + """ + Hier wird ausschließlich nur festgestellt, ob Login und Kennwort vorhanden sind bzw. übereinstimmen. + alles Weitere muss in den Funktionen erfolgen, die 'check_login' aufrufen. + """ + user:User = User.query.filter(User.userid == userid).first() + if user is None or user.password != password: + return None + else: + return user + +def get_users(): + """ + Hier werden derzeit alle User zurückgegeben. + Allerdings werden die userapps eines Users wie folgt gefiltert: + + Wenn es sich bei dem current_user um den SysAdmin handelt, wird nichts gefiltert + + Wenn es sich bei dem current_user um den AppAdmin handelt, werden nur die userapps zurückgegeben, auf die auch + der current_user Zugriff hat. Außerdem wird die Rolle "SysAdmin" innerhalb der userapp.userapp-roles auch nicht + mit zurückgegeben + + Bei allen anderen Arten von Usern werden keine userapps und somit auch keine Rollen zurückgegeben + """ + users = [] + user: User + for user in User.query.order_by(User.userid): + # Wenn der current_user kein SysAdmin ist, soll diese User auch nicht sehen, dass es einen SysAdmin gibt + if not current_user.is_SysAdmin() and user.is_SysAdmin(): + continue + # Wenn innerhalb einer For-Schleife Elemente gelöscht werden, muss auf einer Kopie der + # Liste iteriert werden, da die For-Schleife sonst mit der Nummerierung durcheinanderkommt, weil während + # des Durchlaufs Werte durch das remove "verschwinden" + # Daher wird hier für die For-Schleife mit der Funktion list() eine neue Liste der Origonal-Liste erzeugt + for userapp in list(user.userapps): + # Rollen für bestimmte Application, in denen der zurückzugebende User Rollen innehat, sollen nur zurück- + # gegeben werden, wenn der current_user für diese App SysAdmin oder AppAdmin ist + if not current_user.has_permission(userapp.application.appid, [APP_ADMIN, SYS_ADMIN]): + user.userapps.remove(userapp) + # Wenn der current_user für die Application keine SysAdmin-Rolle hat, soll eine evtl. SysAdmin-Rolle auch + # nicht zurückgegeben werden + if not current_user.has_permission(userapp.application.appid, [SYS_ADMIN]): + for role in list(userapp.userapp_roles): # s.o. wegen remove in for-loop + if role.roleid == SYS_ADMIN: + userapp.userapp_roles.remove(role) + users.append(user) + return users + + +def changepassword(userid:str, oldpassword:str, newpassword1:str, newpassword2:str): + """ + Die Funktion ändert das Kennwort des Users + + -userid: string Die Userid (nicht id) des Users + -oldpassword: string Das bisherige Kennwort des Users + -newpassword1: string Das neue Kennwort des Users + -newpassword2: string Die Wiederholung des neuen Kennwort des Users + """ + if newpassword1 != newpassword2: + raise ValueError("Die neuen Kennworte stimmen nicht überein!") + if newpassword1 is None or newpassword1.strip() == "": + raise ValueError("Es muss ein neues Kennwort angegeben werden!") + user:User = User.query.filter(User.userid == userid).first() + if user is None or user.password != oldpassword: + raise ValueError("Der angegebene Benutzer existiert nicht oder das alte Kennwort ist falsch") + user.password = newpassword1 + session.commit() + return True + +def updateuser(data): + """ + Die User-Daten werden nur geändert, wenn der current_user entweder SysAdmin oder AppAdmin ist + -data: Dict Ein Dictionary mit den Feldern, die beim User geändert werden sollen + """ + if not current_user.has_roles([SYS_ADMIN, APP_ADMIN]): + raise AutorisationError(allowed_roles=[SYS_ADMIN, APP_ADMIN], user_roles=None) + currentuser: User = None + if data["id"] == -1: + currentuser = User() + currentuser.id = None + currentuser.password = "" + session.add(currentuser) + else: + currentuser = User.query.get(data["id"]) + if currentuser is None: + raise Exception ("404") + currentuser.username = data["username"] + currentuser.userid = data["userid"] + currentuser.email = data["email"] + currentuser.telefon = data["telefon"] + session.commit() + +def deleteusers(userids): + """ + Hier werden die User gelöscht, deren userids übergeben werden. Gelöscht werden kann aber nur, + wenn der current_user entweder SysAdmin oder AppAdmin ist. + Wenn der zu löschende User Rollen in Applications hat, auf die der AppAdmin keinen Zugriff hat, + Wird der User ebenfalls nicht gelöscht. + - userids: string[] eine Liste der userids (nicht id) der User, die gelöscht werden sollen + """ + # Wenn er schon mal nirgendwo Sys- oder AppAdmin ist, fliegt er schon mal direkt raus + if not current_user.has_roles([SYS_ADMIN, APP_ADMIN]): + raise AutorisationError(allowed_roles=[SYS_ADMIN, APP_ADMIN], user_roles=None) + # ansonsten: hier dürfen nur die User gelöscht werden, die in denjenigen Apps eine Rolle haben, + # in dene der current_user entweder SysAdmin aod AppAdmin ist. Wenn der User noch eine Rolle in einer App haben, + # auf die der current_user keinen Zugriff hat, darf nicht gelöscht werden + for userid in userids: + user: User = User.query.filter(User.userid == userid).one() + if user is not None: + for userapp in user.userapps: + currentuser_userapp: Userapp = current_user.get_userapp(userapp.application.appid) + if currentuser_userapp is None or len(currentuser_userapp.userapp_roles) == 0: + raise ApplicationError(f"Der Benutzer {user.username} hat noch Rollen in Anwendungen, in denen Sie kein AppAdmin oder SysAdmin sind.", + "Er kann daher nicht gelöscht werden.", + "Der komplette Löschvorgang wurde abgebrochen.", + htmlstatus=404) + session.delete(user) + session.commit() + +def applyuserapprole(data): + """ + Hier kann einem Benutzer eine Rolle für eine bestimmte App entzogen oder zugewiesen werden + + ### Parameter + - data: dict + - parentuserid: integer + - Die id (nicht userid) des Users, für den eine Rolle geändert werden soll + - parantappid: integer + - Die id (nicht appid) der Application, für die eine Rolle geändert werden soll + - roleid: integer + - Die id (nicht roleid) der Rolle, die zugewiesen oder entzogen werden soll + - ischecked: boolean + - 'True' bedeutet, dass die Rolle zugewiesen werden soll, 'False' bedeutet, dass die Rolle entzogen werden soll + """ + user_id = data['parentuserid'] + app_id = data['parentappid'] + ischecked = data['ischecked'] + role_id = data['roleid'] + + user: User = User.query.get(user_id) + if user is None: + raise Exception("Den User gibt es nicht") + app: Application = Application.query.get(app_id) + if app is None: + raise Exception("Die Application gibt es nicht") + role: Role = Role.query.get(role_id) + if role is None: + raise Exception("Die Rolle gibt es nicht") + + # hier darf nur zugewiesen werden, wenn der current_user entweder SysAdmin ist oder AppAdmin in der App, + # für die die Rolle geändert wird + if not current_user.is_SysAdmin() and not current_user.has_permission(app.appid, [APP_ADMIN]): + raise AutorisationError(allowed_roles=[SYS_ADMIN, APP_ADMIN], user_roles=current_user.get_userapproles(app.appid)) + userapp: Userapp = user.get_userapp_by_id(app_id) + if userapp is None: + userapp = Userapp() + userapp.appid = app_id + user.userapps.append(userapp) + userapp_role: Role = userapp.get_role(role_id) + if userapp_role is None and ischecked: + userapp.userapp_roles.append(role) + if userapp_role is not None and not ischecked: + # eine SysAdmin oder ein AppAdmin kann sich selber nicht die Rolle als SysAdmin oder AppAdmin entziehen + if current_user.userid == user.userid: + if ((userapp_role.roleid == APP_ADMIN and current_user.has_permission(app.appid, [APP_ADMIN])) or + (userapp_role.roleid == SYS_ADMIN and current_user.has_permission(app.appid, [SYS_ADMIN]))): + raise ApplicationError("Sie können sich nicht selbst die Rolle als AppAdmin entziehen, da Sie dann keinen Zugriff auf das System mehr hätten!") + userapp.userapp_roles.remove(userapp_role) # remove kann hier einfach so verwendet werden, da nicht über userapp.userapp_roles iteriert wird + session.commit() + + diff --git a/App_Server/root/auth/routes.py b/App_Server/root/auth/routes.py new file mode 100644 index 0000000..b9c2492 --- /dev/null +++ b/App_Server/root/auth/routes.py @@ -0,0 +1,235 @@ +from approot import login_manager +from auth.model import ( + APP_ADMIN, + APP_DOZENT, + APP_USER, + SYS_ADMIN, + Application, + Role, + User, + applications, + roles, + users, +) +from flask import current_app as app +from flask import jsonify, request +from flask_login import login_required, login_user, logout_user + +from .model.errors import ApplicationError, AutorisationError + +print("auth-routes") + + +@login_manager.user_loader +def user_loader(userid): + user: User | None = User.query.filter(User.userid == userid).first() + return user + + +@app.post("/auth/checklogin") +def checklogin(): + """ + Hier wird nur die Authentifizierung überprüft. + Evtl. Autorisierungen anhand der User-Rollen und App-Berechtigungen müssen später bei den + Funktionsaufrufen erfolgen + """ + data = request.json + user: User = users.check_login(data["userid"], data["password"]) + if user is None: + error = ApplicationError( + "Login oder Kennwort stimmen nicht!", + "Anmeldung wurde verweigert", + htmlstatus=403, + ) + return jsonify(error.error), error.htmlstatus + if not user.has_roles([SYS_ADMIN, APP_ADMIN, APP_USER, APP_DOZENT]): + error = ApplicationError( + "Sie haben nicht die passende Rolle zur Nutzung dieser Anwendung.", + "Der Zugang wird verweigert", + htmlstatus=403, + ) + return jsonify(error.error), error.htmlstatus + login_user(user) + result = user.to_dict() + result["isSysAdmin"] = user.is_SysAdmin() + return jsonify(result), 200 + + +@app.post("/auth/changepassword") +@login_required +def changepassword(): + data = request.json + status = 200 + result = {"Message": "", "Success": True} + try: + users.changepassword( + data["Userid"], + data["OldPassword"], + data["NewPassword1"], + data["NewPassword2"], + ) + except Exception as ex: + error = ApplicationError(str(ex), htmlstatus=400) + return error.error, error.htmlstatus + + return jsonify(result), status + + +@app.get("/auth/logout") +def logout(): + logout_user() + return jsonify({"logout": True}), 200 + + +@app.get("/auth/getusers") +@login_required +def getusers(): + """ + Die Rückgabe wird in 'users.get_users()' (s.u.) anhand er App-Berechtigung und der + Rollen gefiltert. + """ + userquery = users.get_users() + result = [] + user: User + for user in userquery: + result.append(user.to_relationaldict()) + return jsonify(result), 200 + + +@app.get("/auth/getapplications") +@login_required +def getapplications(): + """ + Die Rückgabe wird in 'applications.get_applications()' (s.u.) anhand er App-Berechtigung und der + Rollen gefiltert. + """ + appquery = applications.get_applications() + result = [] + app: Application + for app in appquery: + result.append(app.to_dict()) + return jsonify(result), 200 + + +@app.get("/auth/getroles") +@login_required +def getroles(): + rolelist = roles.get_roles() + result = [] + role: Role + for role in rolelist: + result.append(role.to_dict()) + return jsonify(result), 200 + + +@app.post("/auth/updaterole") +@login_required +def updaterole(): + data = request.json + try: + roles.updaterole(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + if str(ex) == "404": + return jsonify("Rolle nicht gefunden"), 404 + else: + return jsonify(str(ex)), 400 + return jsonify({}), 200 + + +@app.post("/auth/deleteroles") +@login_required +def deleteroles(): + data = request.json + try: + roles.deleteroles(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + return jsonify(str(ex)), 400 + return jsonify({}), 200 + + +@app.post("/auth/updateapplication") +@login_required +def updateapplication(): + data = request.json + try: + applications.updateapplication(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + return jsonify(str(ex)), 400 + return jsonify({}), 200 + + +@app.post("/auth/deleteapplications") +@login_required +def deleteapplications(): + data = request.json + try: + applications.deleteapplications(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + return str(ex), 400 + return {}, 200 + + +@app.post("/auth/updateapplicationroles") +@login_required +def updateapplicationroles(): + data = request.json + try: + applications.updateapplicationroles(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + return jsonify(str(ex)), 400 + return jsonify({}), 200 + + +@app.post("/auth/updateuser") +@login_required +def updateuser(): + data = request.json + try: + users.updateuser(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + if str(ex) == "404": + return jsonify("Benutzer nicht gefunden"), 404 + else: + return jsonify(str(ex)), 400 + return jsonify({}), 200 + + +@app.post("/auth/applyuserapprole") +@login_required +def applyuserapprole(): + data = request.json + try: + users.applyuserapprole(data) + except AutorisationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + return jsonify(str(ex)), 400 + return jsonify({}), 200 + + +@app.post("/auth/deleteusers") +@login_required +def deleteusers(): + data = request.json + try: + users.deleteusers(data) + except ApplicationError as ex: + return jsonify(ex.error), ex.htmlstatus + except Exception as ex: + return jsonify(str(ex)), 400 + return {}, 200 + + +print("########## auth.routes importiert") diff --git a/App_Server/root/config.ini b/App_Server/root/config.ini new file mode 100644 index 0000000..bd44b9a --- /dev/null +++ b/App_Server/root/config.ini @@ -0,0 +1,7 @@ +[DEBUG] +debug=True + +[APP] +projectname=kbp_crm +version=00.00.01 +flasksecret=Hier_muss_ein_sicherer_schluessel_eingesetzt_werden \ No newline at end of file diff --git a/App_Server/root/db.py b/App_Server/root/db.py new file mode 100644 index 0000000..f9f255b --- /dev/null +++ b/App_Server/root/db.py @@ -0,0 +1,10 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + +dbauth = SQLAlchemy(model_class=Base) +dbapp = dbauth diff --git a/App_Server/root/debug b/App_Server/root/debug new file mode 100644 index 0000000..cd8004f --- /dev/null +++ b/App_Server/root/debug @@ -0,0 +1,2 @@ +Wenn diese Datei vorhanden ist, dann startet der Python/Flask-Server im DEBUG-Modus +Wenn die Datei nicht vorhanden ist, startet Python/Flask nicht im DEBUG-Modus \ No newline at end of file diff --git a/App_Server/root/requirements.txt b/App_Server/root/requirements.txt new file mode 100644 index 0000000..1d81997 --- /dev/null +++ b/App_Server/root/requirements.txt @@ -0,0 +1,6 @@ +SQLAlchemy +Flask +Flask-SQLAlchemy +Flask-Login +waitress +# pyodbc \ No newline at end of file diff --git a/App_Server/root/routes/general.py b/App_Server/root/routes/general.py new file mode 100644 index 0000000..ea28741 --- /dev/null +++ b/App_Server/root/routes/general.py @@ -0,0 +1,33 @@ +from flask import current_app as app, render_template, jsonify +from datetime import datetime +from flask_login import login_required + + +@app.route("/") +@app.route("/index.html") +def index(): + scriptfile = "{0}_{1}.js".format(app.config["PROJEKTNAME"], app.config["APPVERSION"]) + if app.config["DEBUG"] is False: + scriptfile = scriptfile.replace(".js", ".min.js") + return render_template( + "index.html", + apptitle = app.config["PROJEKTNAME"], + scriptfile = scriptfile + ) + +@app.route("/xhr/health-check") +def health_check(): + zeit = datetime.now().isoformat() + result = { + 'result': "success", + 'time': zeit, + 'server': "n/a", #_server, + 'projectname': "n/a", #_projektname, + 'clientappversion': "N/A" #_version + } + return result # https://flask.palletsprojects.com/en/3.0.x/quickstart/#about-responses + +@app.route("/xhr/getinfo") +@login_required +def get_info(): + return {"info": "pustekuchen"} \ No newline at end of file diff --git a/App_Server/root/runserver.py b/App_Server/root/runserver.py new file mode 100644 index 0000000..3ea7615 --- /dev/null +++ b/App_Server/root/runserver.py @@ -0,0 +1,20 @@ +from waitress import serve +from werkzeug import serving +from approot import init_app + + +def run_server(): + try: + if app.config["DEBUG"] is False: + _server = "waitress" + # serve(app, listen='nginx-proxy:8080') # Funktioniert aus unbekannten Gründen nicht. Der Hostname wird im Docker Container nicht korrekt aufgelöst + serve(app, listen='*:8080') + else: + _server = "werkzeug" + serving.run_simple("0.0.0.0", 8080, app, use_reloader=True) + except Exception as ex: + print(str(ex)) + +if __name__ == '__main__': + app = init_app() + run_server() \ No newline at end of file diff --git a/App_Server/root/settings.py b/App_Server/root/settings.py new file mode 100644 index 0000000..cba5e8b --- /dev/null +++ b/App_Server/root/settings.py @@ -0,0 +1,26 @@ +from pathlib import Path +from configparser import ConfigParser + +class Configuration: + def __init__(self, configfilename:str|Path): + self.config:ConfigParser = ConfigParser() + self.config.read(configfilename) + + @property + def debug(self) -> bool: + return self.config["DEBUG"].getboolean("debug") + + @property + def clientapp_version(self) -> str: + return self.config["APP"].get("version") + + @property + def app_projectname(self) -> str: + return self.config["APP"].get("projectname") + + @property + def flask_secret(self) -> str: + return self.config["APP"].get("flasksecret") + + + diff --git a/App_Server/root/templates/index.html b/App_Server/root/templates/index.html new file mode 100644 index 0000000..dbd41d6 --- /dev/null +++ b/App_Server/root/templates/index.html @@ -0,0 +1,54 @@ + + + + + + + + {{apptitle}} + + + + + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/App_Server/scripts/init.sh b/App_Server/scripts/init.sh new file mode 100644 index 0000000..c7583ad --- /dev/null +++ b/App_Server/scripts/init.sh @@ -0,0 +1,16 @@ +# Python virtual Environment anlegen +cd /usr/opt/pythonapp +# Wenn es die Datei pyvenv.cfg im Unterverzeichnis env nocht nicht gibt, muss das +# virtuelle Environment angelegt werden +# if [ ! -s env/pyvenv.cfg ] +# then +# echo "Creating Python Virtual environment..." +# python -m venv --upgrade-deps /usr/opt/pythonapp/env +# echo "Python Virtual environment created." +# fi +echo "Installing Python requirements..." +env/bin/pip install -r root/requirements.txt +echo "Python requirements installed." + +echo "Starting Python-Server..." +env/bin/python root/runserver.py \ No newline at end of file diff --git a/App_Server/scripts/inittypescriptdev.sh b/App_Server/scripts/inittypescriptdev.sh new file mode 100644 index 0000000..97c907e --- /dev/null +++ b/App_Server/scripts/inittypescriptdev.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Prüfe, ob node_modules bereits existiert und ob es nicht leer ist +# Das ist wichtig, da das node_modules durch ein bind mount in den Container eingebunden wird +# und daher bei Start des Containers bereits existiert +if [ ! -d "/usr/opt/pythonapp/Client/node_modules" ] || [ -z "$(ls -A /usr/opt/pythonapp/Client/node_modules)" ]; then + echo "node_modules nicht gefunden oder leer. Führe npm install aus..." + cd /usr/opt/pythonapp/Client || { echo "Fehler: /usr/opt/pythonapp/Client nicht gefunden"; exit 1; } + + # Prüfe, ob package.json existiert + if [ -f "package.json" ]; then + echo "package.json gefunden. Installiere Abhängigkeiten..." + npm install + echo "npm install abgeschlossen." + else + echo "Fehler: package.json in /usr/opt/pythonapp/Client nicht gefunden!" + exit 1 + fi +fi + +# Führe das eigentliche CMD aus (z. B. python src/mein_skript.py) +# Zuerst wieder in die Python-Umgebung zurück, da von dort die Anwendung gestartet wird +cd /usr/opt/pythonapp/App_Server || { echo "Fehler: /usr/opt/pythonapp/App_Server nicht gefunden"; exit 1; } +echo "Starte Anwendung..." +exec "$@" \ No newline at end of file diff --git a/Client/Components/auth/applicationpage.ts b/Client/Components/auth/applicationpage.ts new file mode 100644 index 0000000..ea672f8 --- /dev/null +++ b/Client/Components/auth/applicationpage.ts @@ -0,0 +1,616 @@ +import m from "mithril" +import { Treetable, iTreetable, iTreenode, Treenode, TopbarButton, iTopbarButton, changeTreenodes } from "../widgets/Treetable_V2" +import {ModalType, ModalView, iModalView} from "../widgets/Modal" +import { iApplication} from "./models/applications" +import { iRole } from "./models/roles" +import {gridMountpoints} from "../mainlayout" +import {WrongEditSelectionMessage, iWrongEditSelectionMessage, showServerError} from "./authmessages" + +const MOVERIGHT = "Die markierten und nicht markierten Rollen werden den markierten Anwendungen zugeordnet" + +const localDataModel = { + applist: [] as iApplication[], + rolelist: [] as iRole[], + appnodes: [] as iTreenode[], + rolenodes: [] as iTreenode[], + loadData: () => { + Promise.all([ + m.request({ + method: "GET", + url: "/auth/getapplications" + }).then((result) => localDataModel.applist = result as iApplication[]), + m.request({ + method: "GET", + url: "/auth/getroles" + }).then((result) => {localDataModel.rolelist = result as iRole[]}) + ]) + .then (() => { + localDataModel.appnodes = [] + localDataModel.rolenodes = [] + localDataModel.applist.map((app: iApplication) => { + let appnode: iTreenode = new Treenode(app, false, []) + app.roles.forEach((role: iRole) => { + let newChildnode = new Treenode(role) + newChildnode.isChecked = true + appnode.children.push(newChildnode) + }) + appnode.children.sort(sortRoleNodes) + localDataModel.appnodes.push(appnode) + }) + localDataModel.rolelist.map((role: iRole) => { + let rolenode: iTreenode = new Treenode(role, false, []) + localDataModel.rolenodes.push(rolenode) + }) + localDataModel.rolenodes.sort(sortRoleNodes) + changeTreenodes(localDataModel.appnodes, true) + }) + }, + updateRole: (role: iRole) => { + m.request ({ + method: "POST", // Hier muss POST verwendet werden, weil GET keinen Body hat und Parameter nur im URL transportieren kann + url: "/auth/updaterole", + body: role + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + }, + deleteRoles: (roles: iRole[]) => { + let roleids: string[] = [] + roles.forEach((role: iRole) => { + roleids.push(role.roleid) + }) + m.request({ + method: "POST", + url: "/auth/deleteroles", + body: roleids + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + }, + updateApplication: (app: iApplication) => { + m.request ({ + method: "POST", // Hier muss POST verwendet werden, weil GET keinen Body hat und Parameter nur im URL transportieren kann + url: "/auth/updateapplication", + body: app + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + }, + deleteApplications: (apps: iApplication[]) => { + let appids: string[] = [] + apps.forEach((app: iApplication) => { + appids.push(app.appid) + }) + m.request({ + method: "POST", + url: "/auth/deleteapplications", + body: appids + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + }, + updateApplicationRoles: () => { + interface iApplicationRole { + appid: string + roles: {roleid: string, isChecked: boolean}[] + } + let applicationroles: iApplicationRole[] = [] + localDataModel.appnodes.forEach((appnode: iTreenode) => { + let app: iApplication = appnode.nodevalue + let approle: iApplicationRole = { + appid: app.appid, + roles: [] + } + appnode.children.forEach((rolenode: iTreenode) => { + let role: iRole = rolenode.nodevalue + approle.roles.push({roleid: role.roleid, isChecked: rolenode.isChecked}) + }) + applicationroles.push(approle) + }) + m.request({ + method: "POST", + url: "/auth/updateapplicationroles", + body: applicationroles + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + } +} + +export const applicationpage: m.ClosureComponent = (vnode) => { + return { + oninit: (vnode) => { + localDataModel.loadData() + }, + view: (vnode) => { + return m("div.w3-container.w3-content", { + style: {'max-width': '80%', display: 'flex', 'flex-direction': 'column', height: 'inherit', 'overflow-y': 'inherit'} + }, + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m("div.w3-cell-row", + m("div.w3-cell.w3-padding.w3-mobile", + m("h1", "Rollen"), + m(Treetable, + { + treenodes: localDataModel.rolenodes, + startexpanded: false, + onRowMarked: (node: iTreenode) => { + //alert(node.nodevalue['roleid']) + }, + columnmapper: (node: iTreenode) => { + let role = node.nodevalue as iRole + return [ + m("span.w3-button", + { + style: {padding: '0px'}, + onclick: () => { + node.isChecked = !node.isChecked + } + }, + m("i.material-icons.w3-theme-light", node.isChecked?"check_box":"check_box_outline_blank") + ), + m("div", role.level), + m("div", role.roleid), + m("div", role.name) + ] + } + } as iTreetable, + m(TopbarButton, + { + onclick: applyRoles, + tooltip: MOVERIGHT + } as iTopbarButton, + m("i.material-icons", "keyboard_double_arrow_right") + ), + m(TopbarButton, + { + onclick: () => {m.mount(gridMountpoints.modalhook, { + view: (vnode) => { + return m(RoleForm, {role: {id: -1, level: 0, name: "", roleid: ""} as iRole}) + } + })} + } as iTopbarButton, + m("i.material-icons", "add") + ), + m(TopbarButton, + { + onclick: () => {m.mount(gridMountpoints.modalhook, DeleteRolesMessage)} + } as iTopbarButton, + m("i.material-icons", "delete_forever") + ), + m(TopbarButton, + { + onclick: () => { + let roles: iRole[] = [] + localDataModel.rolenodes.forEach((rolenode: Treenode) => { + if (rolenode.isChecked) { + roles.push(rolenode.nodevalue) + } + }) + if (roles.length != 1) { + m.mount(gridMountpoints.modalhook, WrongRoleEditSelectionMessage) + } + else { + m.mount(gridMountpoints.modalhook, { + view: (vnode) => { + return m(RoleForm, { + role: roles[0] as iRole + }) + } + }) + } + } + } as iTopbarButton, + m("i.material-icons", "edit") + ) + ) + ), + m("div.w3-cell.w3-padding.w3-mobile", + m("h1", "Anwendungen"), + m(Treetable, + { + treenodes: localDataModel.appnodes, + startexpanded: true, + columnmapper: (node: iTreenode) => { + let cols: any[] = [] + // Hier wird "Duck-Typing" genutzt, um den Typ von node.nodevalue zu ermitteln + // Je nach Typ werden die Spalten der Tabelle dann anders befüllt + if (node.nodevalue['appid'] != undefined) { + let app = node.nodevalue as iApplication + cols[0] = "enabled" + cols[1] = app.appid + cols[2] = app.name + } + else if (node.nodevalue['roleid'] != undefined) { + let role = node.nodevalue as iRole + cols[0] = "disabled" + cols[1] = role.roleid + cols[2] = role.name + } + + return [ + m(cols[0] == "enabled"?"span.w3-button":"span", + { + style: {padding: '0px'}, + onclick: () => { + if (cols[0] == "enabled") { + node.isChecked = !node.isChecked + } + } + }, + m("i.material-icons.w3-theme-light", node.isChecked?"check_box":"check_box_outline_blank") + ), + m("div", cols[1]), + m("div", cols[2]) + ] + + } + } as iTreetable, + m(TopbarButton, + { + onclick: applyRoles, + tooltip: MOVERIGHT + } as iTopbarButton, + m("i.material-icons", "keyboard_double_arrow_right") + ), + m(TopbarButton, + { + onclick: () => {m.mount(gridMountpoints.modalhook, + { + view: (vnode) => { + return m(ApplicationForm, {application: {id: -1} as iApplication} as iApplicationForm) + } + } + )} + } as iTopbarButton, + m("i.material-icons", "add") + ), + m(TopbarButton, + { + onclick: () => {m.mount(gridMountpoints.modalhook, DeleteApplicatonsMessage)} + } as iTopbarButton, + m("i.material-icons", "delete_forever") + ), + m(TopbarButton, + { + onclick: () => { + let apps: iApplication[] = [] + localDataModel.appnodes.forEach((appnode: Treenode) => { + if (appnode.isChecked) { + apps.push(appnode.nodevalue) + } + }) + if (apps.length != 1) { + m.mount(gridMountpoints.modalhook, WrongApplicationEditSelectionMessage) + } + else { + m.mount(gridMountpoints.modalhook, { + view: (vnode) => { + return m(ApplicationForm, { + application: apps[0] as iApplication + } as iApplicationForm) + } + }) + } + } + } as iTopbarButton, + m("i.material-icons", "edit") + ) + ) + ) + ) + ) + ) + } + } +} + +function sortRoleNodes(node1: iTreenode, node2: iTreenode) { + // es soll zuerst nach Level und dann nach rowid sortiert werden + let v1 = (node1.nodevalue['level'].toString().padStart(6,'0') + node1.nodevalue['roleid']).toLocaleLowerCase('de-de') + let v2 = (node2.nodevalue['level'].toString().padStart(6,'0') + node2.nodevalue['roleid']).toLocaleLowerCase('de-de') + if (v1 < v2) { + return -1 + } + if (v1 > v2) { + return 1 + } + return 0 +} + +function applyRoles() { + let checkedApps = localDataModel.appnodes.filter((appnode: iTreenode) => { + return appnode.isChecked + }) + if (checkedApps.length == 0) { + m.mount(gridMountpoints.modalhook, ErrorMessage) + return + } + + m.mount(gridMountpoints.modalhook, {view: () => {return m(ApplyRolesWarning, {applyroles: doit})}}) + + function doit() { + checkedApps.forEach((appnode: iTreenode) => { + localDataModel.rolenodes.forEach((rolenode: iTreenode) => { + let approle: iTreenode = appnode.children.find((child: iTreenode) => { + return child.nodevalue['roleid'] === rolenode.nodevalue['roleid'] + }) + if (approle != undefined) { + approle.isChecked = rolenode.isChecked + } + else if (rolenode.isChecked) { + let newapprole = new Treenode(rolenode.nodevalue, false, []) + newapprole.isChecked = rolenode.isChecked + appnode.children.push(newapprole) + } + }) + }) + localDataModel.updateApplicationRoles() + } +} + +const ErrorMessage: m.Component = { + view: (vnode) => { + return ( + m(ModalView, + { + title: "Fehler", + type: ModalType.ERROR, + width: '33%' + } as iModalView, + m("p", "Es wurden noch keine Ziel-Anwendungen markiert!"), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "OK"), + ] + ) + ) + ) + } +} + +export interface iApplyRolesWaring { + applyroles: Function +} +const ApplyRolesWarning: m.Component = { + view: (vnode) => { + return ( + m(ModalView, + { + title: "Hinweis", + type: ModalType.MESSAGE, + width: '33%' + } as iModalView, + m("p", "Wenn Rollen nicht ausgewählt sind bedeutet dies, dass die Rollen in den ausgewählten Anwendungen gelöscht werden!"), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "Abbruch"), + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null); vnode.attrs.applyroles()}}, "OK"), + ] + ) + ) + ) + } +} + +const WrongRoleEditSelectionMessage: m.Component = { + view: () => { + return m(WrongEditSelectionMessage, {elementname: "Rolle"} as iWrongEditSelectionMessage) + } +} +const WrongApplicationEditSelectionMessage: m.Component = { + view: () => { + return m(WrongEditSelectionMessage, {elementname: "Anwendung"} as iWrongEditSelectionMessage) + } +} + +const DeleteRolesMessage: m.ClosureComponent = (vnode) => { + let roles: iRole[] + return { + oninit: (vnode) => { + roles = [] + localDataModel.rolenodes.forEach((rolenode: iTreenode) => { + if (rolenode.isChecked) { + roles.push(rolenode.nodevalue) + } + }) + }, + view: (vnode) => { + return ( + m(ModalView, + { + title: "Rollen löschen", + type: ModalType.MESSAGE, + width: '33%' + } as iModalView, + m("p", "Sie wollen die folgenden Rollen löschen:"), + m("ul", + roles.map((role: iRole) => { + return m("li", role.roleid) + }) + ), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", + { + style: {'margin-left': '4px'}, + onclick: () => { + localDataModel.deleteRoles(roles); + m.mount(gridMountpoints.modalhook, null) + }}, "Löschen"), + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "Abbrechen"), + ] + ) + ) + ) + } + } +} + + +interface iRoleForm { + role: iRole +} +const RoleForm: m.ClosureComponent = () => { + let form: HTMLFormElement = null; + let currentrole: iRole + function reset (e) { + form.reset() + } + function unmount () { + m.mount(gridMountpoints.modalhook, null) + } + function submit (e) { + e.preventDefault() + let formdata: FormData = new FormData(e.target as HTMLFormElement) + + for (let element of formdata) { + currentrole[element[0]] = element[1] + } + localDataModel.updateRole(currentrole) + unmount() + } + return { + view: (vnode) => { + currentrole = (vnode.attrs.role === undefined) ? {} as iRole : vnode.attrs.role + return m(ModalView, + { + title: "Rolle", + type: ModalType.MESSAGE, + width: 'max-content' + } as iModalView, + + m("form.w3-padding-16", {oncreate: (vnode) => {form = vnode.dom as HTMLFormElement}, onsubmit: submit}, + m("label", "ID (intern)"), + m("div.w3-padding-small", currentrole.id), + m("label", "Rollen-ID"), + m("input.w3-input.w3-border", {name: 'roleid', type: 'text', value: currentrole.roleid}), + m("label", "Name"), + m("input.w3-input.w3-border", {name: 'name', type: 'text', value: currentrole.name}), + m("label", "Level"), + m("input.w3-input.w3-border", {name: 'level', type: 'number', value: currentrole.level}), + m("label", "Beschreibung"), + m("input.w3-input.w3-border", {name: 'beschreibung', type: 'text', value: currentrole.beschreibung}), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, type: "submit"}, "Speichern"), + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, onclick: unmount, type: "button"}, "Abbrechen"), + m("button.w3-button.w3-theme.w3-right", {onclick: reset, type: "button"}, "Zurücksetzen") + + ] + ) + ) + ) + } + } +} + +interface iApplicationForm { + application: iApplication +} +const ApplicationForm: m.ClosureComponent = () => { + let form: HTMLFormElement = null; + let currentapp: iApplication + let isDirty = false + function reset (e) { + form.reset() + } + function unmount () { + if (isDirty) { + alert("is Dirty") + } + m.mount(gridMountpoints.modalhook, null) + } + function submit (e) { + e.preventDefault() + let formdata: FormData = new FormData(e.target as HTMLFormElement) + + for (let element of formdata) { + currentapp[element[0]] = element[1] + } + localDataModel.updateApplication(currentapp) + unmount() + } + return { + view: (vnode) => { + currentapp = (vnode.attrs.application === undefined) ? {} as iApplication : vnode.attrs.application + return m(ModalView, + { + title: "Anwendung", + type: ModalType.MESSAGE, + width: 'max-content' + } as iModalView, + + m("form.w3-padding-16", {oncreate: (vnode) => {form = vnode.dom as HTMLFormElement}, onsubmit: submit}, + m("label", "ID (intern)"), + m("div.w3-padding-small", currentapp.id), + m("label", "Application-ID"), + m("input.w3-input.w3-border", {name: 'appid', type: 'text', value: currentapp.appid}), + m("label", "Name"), + m("input.w3-input.w3-border", {name: 'name', type: 'text', value: currentapp.name}), + m("label", "Minimum-Version"), + m("input.w3-input.w3-border", {name: 'minversion', type: 'text', value: currentapp.minversion}), + m("label", "Beschreibung"), + m("input.w3-input.w3-border", {name: 'beschreibung', type: 'text', value: currentapp.beschreibung}), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, type: "submit"}, "Speichern"), + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, onclick: unmount, type: "button"}, "Abbrechen"), + m("button.w3-button.w3-theme.w3-right", {onclick: reset, type: "button"}, "Zurücksetzen") + + ] + ) + ) + ) + } + } +} + +const DeleteApplicatonsMessage: m.ClosureComponent = (vnode) => { + let apps: iApplication[] + return { + oninit: (vnode) => { + apps = [] + localDataModel.appnodes.forEach((appnode: iTreenode) => { + if (appnode.isChecked) { + apps.push(appnode.nodevalue) + } + }) + }, + view: (vnode) => { + return ( + m(ModalView, + { + title: "Anwendungen löschen", + type: ModalType.MESSAGE, + width: '33%' + } as iModalView, + m("p.w3-panel.w3-red", + [ + "Vor dem Löschen muss sichergestellt sein, dass es noch mindestens eine App gibt, in der die Rolle 'SysAdmin' ", + "vergeben ist und in der ein Benutzer diese Rolle besitzt!" + ] + ), + m("p", "Sie wollen die folgenden Anwendungen löschen:"), + m("ul", + apps.map((app: iApplication) => { + return m("li", app.appid) + }) + ), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", + { + style: {'margin-left': '4px'}, + onclick: () => { + localDataModel.deleteApplications(apps); + m.mount(gridMountpoints.modalhook, null) + }}, "Löschen"), + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "Abbrechen"), + ] + ) + ) + ) + } + } +} \ No newline at end of file diff --git a/Client/Components/auth/appmanagementpage.ts b/Client/Components/auth/appmanagementpage.ts new file mode 100644 index 0000000..715dbfa --- /dev/null +++ b/Client/Components/auth/appmanagementpage.ts @@ -0,0 +1,40 @@ +import m from "mithril" +import { get_menustack, menubar } from "../header" +import { userlist } from "./userpage" +import { applicationpage } from "./applicationpage" + +let mountpoint = null + +function close_page() { + get_menustack().pop() + m.route.set("/blankpage") +} +function show_userlist() { + m.mount(mountpoint, userlist) +} +function show_applist() { + m.mount(mountpoint, applicationpage) +} + +export const appmanagementpage: m.Component = { + oncreate: (vnode) => { + mountpoint = vnode.dom + }, + oninit: (vnode) => { + get_menustack().push(menu) + }, + view: (vnode) => { + return m("div", m(userlist)) + } +} + +const menu: m.Component = { + view: (vnode) => { + return m(menubar, [ + m("a.w3-button", {onclick: show_userlist}, "Benutzer"), + sessionStorage.getItem("CurrentUserIsSysAdmin") != null ? + m("div.w3-button", {onclick: show_applist}, "Anwendungen"):null, + m("div.w3-button.w3-right", {onclick: close_page}, "Beenden"), + ]) + } +} \ No newline at end of file diff --git a/Client/Components/auth/authmessages.ts b/Client/Components/auth/authmessages.ts new file mode 100644 index 0000000..cb2e0b6 --- /dev/null +++ b/Client/Components/auth/authmessages.ts @@ -0,0 +1,76 @@ +import m from "mithril" +import { gridMountpoints } from "../mainlayout" +import {ModalType, ModalView, iModalView} from "../widgets/Modal" + + +export interface iWrongEditSelectionMessage{ + elementname: string +} +export const WrongEditSelectionMessage: m.Component = { + view: (vnode) => { + return ( + m(ModalView, + { + title: "Falsche Auswahl", + type: ModalType.ERROR, + width: '33%' + } as iModalView, + m("p", `Zum Editieren muss genau 1 ${vnode.attrs.elementname} ausgewählt sein!`), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "OK"), + ] + ) + ) + ) + } +} + +export interface iServerErrorMessage { + error: { + messages: string[], + status: number + } +} +export const ServerErrorMessage: m.Component = { + view: (vnode) => { + return ( + m(ModalView, + { + title: "Fehler", + type: ModalType.ERROR, + width: "33%" + } as iModalView, + m("div.w3-panel", + vnode.attrs.error.messages.map((message) => { + return m("div", message) + }), + m("div.w3-small", `(HTML-Status: ${vnode.attrs.error.status})`) + ), + m("div.w3-bar.w3-padding-16", + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "OK"), + ) + ) + ) + } +} + +export function showServerError (e) { + let servererror:iServerErrorMessage = {error: {messages:[], status: e.code}} + console.log(JSON.stringify(e), e.message) + if (e.response != undefined && e.response["messages"] != undefined) { + if (!Array.isArray(e.response["messages"])) { + e.response["messages"] = [e.response["messages"]] + } + servererror.error.messages = e.response.messages + } + else if (e.response != undefined) { + servererror.error.messages = [JSON.stringify(e.response)] + } + else { + servererror.error.messages = [JSON.stringify(e)] + } + m.mount(gridMountpoints.modalhook, { + view: () => {return m(ServerErrorMessage, {error: servererror.error } as iServerErrorMessage)} + }) +} \ No newline at end of file diff --git a/Client/Components/auth/login.ts b/Client/Components/auth/login.ts new file mode 100644 index 0000000..a379855 --- /dev/null +++ b/Client/Components/auth/login.ts @@ -0,0 +1,117 @@ +import m from "mithril"; +import {ModalView, iModalView} from "../widgets/Modal" +import {iUser} from "./models/users" +import {showServerError} from "./authmessages" +import { headerdata } from "../header"; + +export interface iLoginscreen { + setnextpage: string +} +export const loginscreen: m.ClosureComponent = (vnode) => { + let form: HTMLFormElement = null; + function reset (e) { + form.reset() + } + function checklogin(e: Event) { + e.preventDefault() + let formdata: FormData = new FormData(e.target as HTMLFormElement) + let user: iUser + m.request ({ + method: "POST", // Hier muss POST verwendet werden, weil GET keinen Body hat und Parameter nur im URL transportieren kann + url: "/auth/checklogin", + body: Object.fromEntries(formdata.entries()) + }) + .then((result) => { + user = result as iUser; + // global.appglobals.currentuser = user.username + // global.appglobals.currentuserid = user.userid + sessionStorage.setItem("CurrentUser", user.username); + headerdata.username = user.username + headerdata.userid = user.userid + // nur wenn es sich um einen SysAdmin handelt, wird der SessionStorage gesetzt. Will man später wissen, + // ob der aktuelle User ein SysAdmin ist, braucht man nur auf das Vorhandensein des Schlüssels abzufragen + // und nicht auch noch auf den Inhalt + if (user.isSysAdmin) { + sessionStorage.setItem("CurrentUserIsSysAdmin", "1"); + } + }) + .then(() => {m.route.set(vnode.attrs.setnextpage)}) + .catch((error) => { + sessionStorage.removeItem("CurrentUser"); + sessionStorage.removeItem("CurrentUserIsSysAdmin"); + showServerError(error) + }) + + } + return { + oninit: (vnode) => { + m.request({ + method: "GET", + url: "/auth/logout" + }) + .then (() => { + sessionStorage.removeItem("CurrentUser"); + sessionStorage.removeItem("CurrentUserIsSysAdmin"); + // global.appglobals.currentuser = "nicht angemeldet" + // global.appglobals.currentuserid = "" + }) + }, + view: (vnode) => { + return m(ModalView, {title: "Login", width: '33%'} as iModalView, + m("form.w3-padding-16", {oncreate: (vnode) => {form = vnode.dom as HTMLFormElement}, onsubmit: checklogin}, + m("label", "Benutzer-ID"), + m("input.w3-input.w3-border", {name: 'userid', type: 'text'}), + m("label", "Kennwort"), + m("input.w3-input.w3-border", {name: 'password', type: 'password'}), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, type: "submit"}, "Login"), + m("button.w3-button.w3-theme.w3-right", {onclick: reset, type: "button"}, "Zurücksetzen"), + ] + ) + ) + ) + } + } +} + +export const changepasswordscreen: m.ClosureComponent = () => { + let form: HTMLFormElement = null; + function reset (e) { + form.reset() + } + function changepassword(e) { + e.preventDefault() + let formdata: FormData = new FormData(e.target as HTMLFormElement) + m.request ({ + method: "POST", // Hier muss POST verwendet werden, weil GET keinen Body hat und Parameter nur im URL transportieren kann + url: "/auth/changepassword", + body: Object.fromEntries(formdata.entries()) + }) + .then(() => {m.route.set("/login")}) + .catch((e) => {showServerError(e)}) + } + return { + view: (vnode) => { + return m(ModalView, {title: "Kennwort ändern", width: '33%'} as iModalView, + m("form.w3-padding-16", {oncreate: (vnode) => {form = vnode.dom as HTMLFormElement}, onsubmit: changepassword}, + m("label", "Benutzer-ID"), + m("input.w3-input.w3-border", {name: 'Userid', type: 'text', value: headerdata.userid, readonly: true}), + m("label", "Altes Kennwort"), + m("input.w3-input.w3-border", {name: 'OldPassword', type: 'password'}), + m("label", "Neues Kennwort"), + m("input.w3-input.w3-border", {name: 'NewPassword1', type: 'password'}), + m("label", "Neues Kennwort"), + m("input.w3-input.w3-border", {name: 'NewPassword2', type: 'password'}), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, type: "submit"}, "Kennwort ändern"), + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, onclick: reset, type: "button"}, "Zurücksetzen"), + m("button.w3-button.w3-theme.w3-right", {onclick: () => {history.back()}, type: "button"}, "Abbrechen"), + ] + ) + ) + ) + } + } +} diff --git a/Client/Components/auth/models/applications.ts b/Client/Components/auth/models/applications.ts new file mode 100644 index 0000000..c36cc6c --- /dev/null +++ b/Client/Components/auth/models/applications.ts @@ -0,0 +1,18 @@ +import {iRole} from "./roles" + +export interface iApplication { + id: number + appid: string + name: string + minversion: string + beschreibung: string + roles: iRole[] +} + +export interface iUserApp { + id: number + userid: number + appid: number + userapp_roles : iRole[] +} + diff --git a/Client/Components/auth/models/roles.ts b/Client/Components/auth/models/roles.ts new file mode 100644 index 0000000..a41777d --- /dev/null +++ b/Client/Components/auth/models/roles.ts @@ -0,0 +1,8 @@ +export interface iRole { + id: number + roleid: string + name: string + level: number + beschreibung: string +} + diff --git a/Client/Components/auth/models/users.ts b/Client/Components/auth/models/users.ts new file mode 100644 index 0000000..c92aad6 --- /dev/null +++ b/Client/Components/auth/models/users.ts @@ -0,0 +1,24 @@ +import { iUserApp } from "./applications" +import { iRole } from "./roles" + +export interface iUser { + id: number + userid: string + username: string + password: string + email: string + telefon: string + isSysAdmin: boolean + userapps: iUserApp[] +} + +export function hasAppRole(user: iUser, appid: number, roleid: number) { + let userapp: iUserApp = user.userapps.find((userapp: iUserApp) => {return userapp.appid == appid}) + if (userapp != undefined) { + let userapprole: iRole = userapp.userapp_roles.find((role: iRole) => {return role.id == roleid}) + if (userapprole != undefined) { + return true + } + } + return false +} \ No newline at end of file diff --git a/Client/Components/auth/userpage.ts b/Client/Components/auth/userpage.ts new file mode 100644 index 0000000..4ff0aa5 --- /dev/null +++ b/Client/Components/auth/userpage.ts @@ -0,0 +1,364 @@ +import m from "mithril" +import {hasAppRole, iUser} from "./models/users" +import {iApplication} from "./models/applications" +import {iRole} from "./models/roles" +import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "../widgets/Treetable_V2" +import {gridMountpoints, maingrid} from "../mainlayout" +import {ModalView, ModalType, iModalView} from "../widgets/Modal" +import { iWrongEditSelectionMessage, WrongEditSelectionMessage, showServerError} from "./authmessages" + + +interface iTabelrow { + rowtype: any + col1: any + cols: any[] + info?: { + parentuserid: number + parentappid: number + roleid: number + ischecked: boolean + } +} + +const localDataModel = { + userlist: [] as iUser[], + applist: [] as iApplication[], + usernodes: [] as iTreenode[], + startexpanded: false, + loadData: () => { + Promise.all([ + m.request({ + method: "GET", + url: "/auth/getusers" + }).then((result) => localDataModel.userlist = result as iUser[]), + m.request({ + method: "GET", + url: "/auth/getapplications" + }).then((result) => localDataModel.applist = result as iApplication[]), + ]) + .then(() => { + localDataModel.usernodes = [] + localDataModel.userlist.map((u) => { + let userrow: iTabelrow = { + rowtype: 'user', + col1: u.userid, + cols: [u.username, u.email, u.telefon] + } + let usernode: iTreenode = new Treenode(userrow, localDataModel.startexpanded, []) + localDataModel.applist.forEach((app: iApplication) => { + let approw: iTabelrow = { + rowtype: "app", + col1: app.appid, + cols: [app.name, app.minversion], + info: { + ischecked: false, + parentuserid: u.id, + parentappid: null, + roleid: null + } + + } + let appnode: Treenode = new Treenode(approw, localDataModel.startexpanded, []) + app.roles.forEach((role: iRole) => { + let ischecked = hasAppRole(u, app.id, role.id) + let rolerow: iTabelrow = { + rowtype: "role", + col1: role.name, + cols: [role.roleid], + info: { + parentuserid: u.id, + parentappid: app.id, + ischecked: ischecked, + roleid: role.id + } + } + let rolenode: iTreenode = new Treenode(rolerow, localDataModel.startexpanded, []) + appnode.children.push(rolenode) + }) + usernode.children.push(appnode) + }) + localDataModel.usernodes.push(usernode) + }) + }) + .catch((e) => { + if (e.code == 401) { + m.route.set("/login") + } + }) + }, + updateUser: (user: iUser) => { + m.request ({ + method: "POST", // Hier muss POST verwendet werden, weil GET keinen Body hat und Parameter nur im URL transportieren kann + url: "/auth/updateuser", + body: user + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + }, + deleteUsers: (users: iUser[]) => { + m.request({ + method: "POST", + url: "/auth/deleteusers", + body: users.map((u: iUser) => {return u.userid}) + }) + .catch((e) => {showServerError(e)}) + .finally(() => localDataModel.loadData()) + }, + applyUserAppRole: (tablerow: iTabelrow) => { + m.request ({ + method: "POST", + url: "/auth/applyuserapprole", + body: tablerow.info + }) + .catch((e) => {showServerError(e); localDataModel.loadData()}) + } +} + +export const userlist: m.ClosureComponent = (vnode) => { + return { + oninit: (vnode) => { + localDataModel.loadData() + }, + view: (vnode) => { + return m("div.w3-container.w3-content", {style: {'max-width': 'min-content', display: 'flex', 'flex-direction': 'column', height: 'inherit', 'overflow-y': 'inherit'}}, + m("h1", "Benutzerliste"), + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m(Treetable, + { + startexpanded: localDataModel.startexpanded, + treenodes: localDataModel.usernodes, + columnmapper: (node: iTreenode) => { + let row: iTabelrow = node.nodevalue + let isChecked = hasCheckedChilds(node) + if (row.rowtype == "user") { + return [ + m("strong", row.col1), + m("strong", row.cols[0]), + m(isChecked?"div.w3-text-green":"div.w3-text-red", {style: {display: 'flex'}}, + // m("div", {style: {padding: '0px', height:'24px', width: '24px'}}, + // m("i.material-icons", isChecked?null:"dangerous"), + // ), + m("div", isChecked?"aktiv":"inaktiv") + ), + m("div.w3-small", + m("div", row.cols[1]), + m("div", row.cols[2]) + ), + m(EditUserButton, {userid: row.col1} as iEditUserButton), + m(DeleteUserButton, {userid: row.col1} as iDeleteUserButton) + ] + } + else if (row.rowtype == "role") { + return [ + m("span.w3-small", row.col1), + m("span.w3-button", { + style: {padding: '0px'}, + onclick: () => { + row.info.ischecked = !row.info.ischecked + localDataModel.applyUserAppRole(row) + } + }, + m("i.material-icons.w3-theme-light", row.info.ischecked?"check_box":"check_box_outline_blank") + ), + m("span.w3-small", row.cols[0]), + m("span.w3-small", row.cols[1]), + m("span", ""), + m("span", "") + ] + } + else { + // rowtype "app" + let isChecked = hasCheckedChilds(node) + return [ + m(isChecked?"span":"strong.w3-text-red", row.col1), + m("div", {style: {padding: '0px', height:'24px', width: '24px'}}, + m("i.material-icons", isChecked?"check":null) + ), + m("span", row.cols[0]), + m("span", row.cols[1]), + m("span", ""), + m("span", "") + ] + } + } + } as iTreetable, + m(TopbarButton, { + onclick: () => { + m.mount(gridMountpoints.modalhook, { + view: (vnode) => { + return m(UserForm, {user: {id: -1} as iUser}) + } + }) + }} as iTopbarButton, + m("i.material-icons", "add") + ) + ) + ) + ) + } + } +} + +function hasCheckedChilds(parenttreenode: iTreenode): boolean { + return parenttreenode.children.some((treenode: iTreenode) => { + if (treenode.hasChildren) { + return hasCheckedChilds(treenode) + } + let tablerow = treenode.nodevalue as iTabelrow + return tablerow.info.ischecked + }) +} + +interface iUserForm { + user: iUser +} +const UserForm: m.ClosureComponent = () => { + let form: HTMLFormElement = null; + let currentuser: iUser + function reset (e) { + form.reset() + } + function unmount () { + m.mount(gridMountpoints.modalhook, null) + } + function submit (e) { + e.preventDefault() + let formdata: FormData = new FormData(e.target as HTMLFormElement) + + for (let element of formdata) { + currentuser[element[0]] = element[1] + } + localDataModel.updateUser(currentuser) + unmount() + } + return { + oninit: (vnode) => { + currentuser = (vnode.attrs.user === undefined) ? {} as iUser : vnode.attrs.user + }, + view: (vnode) => { + return m(ModalView, + { + title: "Benutzer", + type: ModalType.MESSAGE, + width: 'max-content' + } as iModalView, + + m("form.w3-padding-16", {oncreate: (vnode) => {form = vnode.dom as HTMLFormElement}, onsubmit: submit}, + m("label", "ID (intern)"), + m("div.w3-padding-small", currentuser.id), + m("label", "Benutzer-ID"), + m("input.w3-input.w3-border", {name: 'userid', type: 'text', value: currentuser.userid}), + m("label", "Name"), + m("input.w3-input.w3-border", {name: 'username', type: 'text', value: currentuser.username}), + m("label", "E-Mail"), + m("input.w3-input.w3-border", {name: 'email', type: 'email', value: currentuser.email}), + m("label", "Telefon"), + m("input.w3-input.w3-border", {name: 'telefon', type: 'tel', value: currentuser.telefon}), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, type: "submit"}, "Speichern"), + m("button.w3-button.w3-theme.w3-right", {style: {'margin-left': '4px'}, onclick: unmount, type: "button"}, "Abbrechen"), + m("button.w3-button.w3-theme.w3-right", {onclick: reset, type: "button"}, "Zurücksetzen") + + ] + ) + ) + ) + } + } +} + +const WrongUserEditSelectionMessage: m.Component = { + view: () => { + return m(WrongEditSelectionMessage, {elementname: "Benutzer"} as iWrongEditSelectionMessage) + } +} + +interface iDeleteUsersMessage { + userid: string +} +const DeleteUsersMessage: m.ClosureComponent = (vnode) => { + let users: iUser[] + return { + oninit: (vnode) => { + users = localDataModel.userlist.filter((u: iUser) => { + return u.userid == vnode.attrs.userid + }) + }, + view: (vnode) => { + return ( + m(ModalView, + { + title: "Benutzer löschen", + type: ModalType.MESSAGE, + width: '33%' + } as iModalView, + m("p", "Sie wollen den folgenden Benutzer löschen:"), + m("ul", + users.map((user: iUser) => { + return m("li", `${user.userid} (${user.username})`) + }) + ), + m("div.w3-bar.w3-padding-16", + [ + m("button.w3-button.w3-theme.w3-right", + { + style: {'margin-left': '4px'}, + onclick: () => { + localDataModel.deleteUsers(users); + m.mount(gridMountpoints.modalhook, null) + }}, "Löschen"), + m("button.w3-button.w3-theme.w3-right", {onclick: () => {m.mount(gridMountpoints.modalhook, null)}}, "Abbrechen"), + ] + ) + ) + ) + } + } +} + +interface iDeleteUserButton { + userid: string +} +export const DeleteUserButton: m.ClosureComponent = () => { + return { + view: (vnode) => { + return m("button.w3-button.w3-bar-item", { + style: {'min-width': '24px', height: '24px', padding: '0'}, + onclick: () => { + m.mount(gridMountpoints.modalhook, {view: () => { + return m(DeleteUsersMessage, {userid: vnode.attrs.userid} as iDeleteUsersMessage) + }}) + }, + title: "Benutzer löschen" + }, + m("i.material-icons", "delete_forever") + ) + } + } +} +interface iEditUserButton { + userid: string +} +export const EditUserButton: m.ClosureComponent = () => { + return { + view: (vnode) => { + return m("button.w3-button.w3-bar-item", { + style: {'min-width': '24px', height: '24px', padding: '0'}, + onclick: () => { + let users: iUser[] = localDataModel.userlist.filter((u:iUser) => { + return u.userid == vnode.attrs.userid + }) + m.mount(gridMountpoints.modalhook, {view: () => { + return m(UserForm, {user: users[0]} as iUserForm) + }}) + }, + title: "Benutzer bearbeiten" + }, + m("i.material-icons", "edit") + ) + } + } +} + + diff --git a/Client/Components/header.ts b/Client/Components/header.ts new file mode 100644 index 0000000..552087f --- /dev/null +++ b/Client/Components/header.ts @@ -0,0 +1,74 @@ +import m from "mithril" + +export var headerdata = { + date: new Date(), + version: "", + username: "Nicht angemeldet", + userid: "Nicht angemeldet", + appheader: "Der gläserne Kunde" +} + +export const header: m.Component = { + view: (vnode) => { + return m("div.w3-row-padding.w3-theme", + m("div.w3-twothird.w3-padding-16", {style: "display: flex; justify-content: space-between;"}, + // Beschreibung des Bildes + m("img", {src: "static/KBplus-Logo.png", height: 80, alt: "KBPlus CRM Logo"}), + m("h1", + m("div.w3-cell", headerdata.appheader) + ) + ), + m("div.w3-third.w3-display-container.w3-small", + m("table.w3-right", + m("tr.w3-hide-small", + m("td", "Datum:"), + m("td", headerdata.date.toLocaleDateString()) + ), + m("tr.w3-hide-small", + m("td", "Version:"), + m("td", headerdata.version) + ), + m("tr", + m("td", "Benutzer:"), + m("td", headerdata.username) + ) + ) + ) + ) + } +} + +export const menubar: m.Component = { + view: (vnode) => { + return m("div.w3-bar.w3-black", + vnode.children + ) + } +} + +let menustack: MenuStack = null + +export function init_menustack(menubarmountpoint):void { + if (menustack == null) {menustack = new MenuStack(menubarmountpoint)} +} +export function get_menustack(): MenuStack { + return menustack +} + +class MenuStack { + private menustack = [] + private menubarmountpoint = null + constructor(menubarmountpoint) { + this.menustack = [] + this.menubarmountpoint = menubarmountpoint + } + push(menubar) { + this.menustack.push(menubar) + m.mount(this.menubarmountpoint, menubar) + } + pop() { + this.menustack.pop() + let menubar = this.menustack[this.menustack.length - 1] + m.mount(this.menubarmountpoint, menubar) + } +} diff --git a/Client/Components/kunde.ts b/Client/Components/kunde.ts new file mode 100644 index 0000000..a193151 --- /dev/null +++ b/Client/Components/kunde.ts @@ -0,0 +1,18 @@ +import m from "mithril" +import {Form, iForm} from "./widgets/form" +import { Input, iInput} from "./widgets/inputs" + +export const kunde: m.Component = { + view: (vnode) => { + return m("div.w3-container.w3-twothird", + m("h1", "Kundenstammblatt"), + m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [ + m("label", "Kundenname (ganz neu)", m(Input, {name: "kundenname", value: "Musterfirma GmbH", type: "text"} as iInput)), + m("label", "Straße", m(Input, {name: "strasse", value: "Musterstraße 1", type: "text"} as iInput)) , + m("label", "PLZ", m(Input, {name: "plz", value: "12345", type: "text"} as iInput)), + m("label", "Ort", m(Input, {name: "ort", value: "Musterstadt", type: "text"} as iInput)), + m("label", "Land", m(Input, {name: "land", value: "Deutschland", type: "text"} as iInput)) + ]) + ) + } +} \ No newline at end of file diff --git a/Client/Components/mainlayout.ts b/Client/Components/mainlayout.ts new file mode 100644 index 0000000..1b75f8f --- /dev/null +++ b/Client/Components/mainlayout.ts @@ -0,0 +1,50 @@ +import m from "mithril"; + +export var gridMountpoints = { + header: null, + menubar: null, + main: null, + modalhook: null +} +export const maingrid: m.ClosureComponent = (vnode) => { + let gridstyle = { + 'display': 'grid', + 'grid-template-rows': 'auto auto 1fr', + 'height': '100vh', + 'width': '100vw' + } + return { + view: (vnode) => { + return [ + m("div", {style: gridstyle}, + [ + m("div", { + oncreate: (vnode) => { gridMountpoints.header = vnode.dom }, + style: {'grid-row': '1'} + }), + m("div", { + oncreate: (vnode) => { gridMountpoints.menubar = vnode.dom }, + style: {'grid-row': '2'} + }), + m("div", + { + //oncreate: (vnode) => { gridMountpoints.main = vnode.dom }, + style: {'grid-row': "3", 'overflow-y': 'auto', 'height': '100%'} + }, + m("div", + { + oncreate: (vnode) => { gridMountpoints.main = vnode.dom }, + style: {'overflow-y': 'auto', height: 'inherit'} + } + ) + ) + ] + ), + m("div#modalhook", { + oncreate: (vnode) => { gridMountpoints.modalhook = vnode.dom} + }) + ] + } + } +} + diff --git a/Client/Components/projekt.ts b/Client/Components/projekt.ts new file mode 100644 index 0000000..8e46d82 --- /dev/null +++ b/Client/Components/projekt.ts @@ -0,0 +1,18 @@ +import m from "mithril" +import {Form, iForm} from "./widgets/form" +import { Input, iInput} from "./widgets/inputs" + +export const Projekt: m.Component = { + view: (vnode) => { + return m("div.w3-container.w3-twothird", + m("h1", "Projekt-Stammblatt"), + m(Form, {showButtons: true, onsubmit: (null), oncancel: (null)} as iForm, [ + m("label", "Kundenname", m(Input, {name: "kundenname", value: "Musterfirma GmbH", type: "text"} as iInput)), + m("label", "Projektname", m(Input, {name: "projektname", value: "Musterprojekt", type: "text"} as iInput)) , + m("label", "Zweck", m(Input, {name: "zweck", value: "Musterzweck", type: "text"} as iInput)), + m("label", "Verantwortlicher", m(Input, {name: "verantwortlicher", value: "König Dickbauch", type: "text"} as iInput)), + m("label", "Startdatum", m(Input, {name: "startdatum", value: "2023-01-01", type: "date"} as iInput)) + ]) + ) + } +} \ No newline at end of file diff --git a/Client/Components/widgets/Modal.ts b/Client/Components/widgets/Modal.ts new file mode 100644 index 0000000..226a609 --- /dev/null +++ b/Client/Components/widgets/Modal.ts @@ -0,0 +1,71 @@ +import m from "mithril" +import {BaseView} from "./base" + +export enum ModalType { + 'MESSAGE', + 'ERROR' +} + +export interface iModalView { + title: string + subtitle: string + topposition: string + width: string + type: ModalType +} +export class ModalView extends BaseView implements iModalView{ + title:string + subtitle:string + topposition:string + width:string + type:ModalType + + constructor (vnode) { + super() + this.title = super.getattr(vnode, "title", "Modal") + this.subtitle = super.getattr(vnode, "subtitle", "") + this.topposition = super.getattr(vnode, "topposition", "10%") + this.width = super.getattr(vnode, "width", 'max-content') + this.type = super.getattr(vnode, 'type', ModalType.MESSAGE) + } + view (vnode) { + return m("div.w3-modal", {style: {display:'block'}}, + m("div.w3-modal-content.w3-card-4", {style: {width: this.width, top: this.topposition}}, + [ + m(modalHeader, {type: this.type}, + [ + m(title, this.title), + (this.subtitle ? m(subtitle, this.subtitle) : null) + ] + ), + m(modalContent, vnode.children) + ] + ) + ) + } +} + +const title = { + view: (vnode) => { + return m("h1", vnode.children) + } +} +const subtitle = { + view: (vnode) => { + return m("h3", vnode.children) + } +} +const modalHeader = { + view: (vnode) => { + let color = ".w3-theme" + if (vnode.attrs["type"] == ModalType.ERROR) { + color = ".w3-red" + } + return m("header.w3-container" + color, vnode.children) + } +} +const modalContent = { + view: (vnode) => { + return m("div.w3-container.w3-theme-l1", vnode.children) + } +} diff --git a/Client/Components/widgets/Treetable_V2.ts b/Client/Components/widgets/Treetable_V2.ts new file mode 100644 index 0000000..7bc46b9 --- /dev/null +++ b/Client/Components/widgets/Treetable_V2.ts @@ -0,0 +1,231 @@ +import m from "mithril" + +export interface iTreenode { + nodevalue: any + children: iTreenode[] + expand: boolean + hasChildren: boolean + isChecked?: boolean +} + +export class Treenode implements iTreenode { + nodevalue:any = null + children: iTreenode[] = [] + expand: boolean = true + isChecked?: boolean = false + constructor(nodevalue: any = null, expand: boolean = false, children: iTreenode[] = [], isChecked: boolean = false) { + this.nodevalue = nodevalue + this.children = children + this.expand = expand + this.isChecked = isChecked + } + get hasChildren() { + return (this.children.length > 0) + } +} + +export interface Columnmapper { + (row:iTreenode) : any[] +} + +export interface iTreetable { + treenodes: iTreenode[] + columnmapper: Columnmapper + onRowMarked?: (node: iTreenode) => void + filter?: (row:iTreenode, searchvalue:string) => boolean +} + +export const Treetable: m.ClosureComponent = (vnode) => { + let onRowMarked = vnode.attrs.onRowMarked === undefined ? (node: iTreenode) => {} : vnode.attrs.onRowMarked + let expandtoggle: boolean = true + let searchvalue:string = "" + function setSearchvalue(v:string) { + searchvalue = v + } + return { + view: (vnode) => { + return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}}, + m("div.w3-bar.w3-theme", + m(TopbarButton, { + onclick: () => { + expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle) + }}, + m("i.material-icons", "unfold_more") + ), + m(TopbarButton, { + onclick: () => { + expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle) + }}, + m("i.material-icons", "unfold_less") + ), + vnode.children, + (vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null + ), + m("div", {style: {height: '100%'}}, + m("div", {style: {height: 'calc(100% - 32px', overflow: 'auto', width: '100%'}}, + m("table.w3-table", {style: {'overflow-y': 'auto'}}, + vnode.attrs.treenodes.map((tn:iTreenode, z) => { + if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) { + return m(Treerow, { + // ACHTUNG: Änderungen hier müssen unten beim rekursiven Aufruf der Treerow-Funktion ebenfalls eingebaut werden + treenode: tn, + level: 0, + columnmapper1: vnode.attrs.columnmapper, + onRowMarked: onRowMarked, + isEven: ((z % 2) == 0)} as iTreerow) + } + else { + return null + } + }) + ) + ) + ) + ) + } + } +} + +interface iTreerow { + treenode: iTreenode + level: number + columnmapper1: Columnmapper + onRowMarked: (node: iTreenode) => void + isEven?: boolean +} +let Treerow: m.ClosureComponent = (vnode) => { + + return { + view: (vnode) => { + return [ + m("tr.w3-border-theme", {onmouseup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode), + onkeyup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode), + class: (vnode.attrs.isEven ? "w3-tablerow-even" : "w3-tablerow-odd"), + style: {'border-top-style': 'solid', 'border-top-width': '1px'} + }, + m("td", // Zunächst wird ein td-Element für die erste Spalte der Tabelle erzeugt, damit dort die + // Einrückung in Abhängigkeit vom evtl. Vorhandensein von Kind-Elementen erledigt wird + m("div", {style: {width: 'max-content', 'margin-left': `${(vnode.attrs.level * 25)}px`}} , + vnode.attrs.treenode.hasChildren + // Wenn der Treenode Kinder hat, dann soll der "expand_more/expand_less"-Button + // angezeigt werden + ? m("span.w3-button", { + onclick: () => {changeTreenode(vnode.attrs.treenode, !vnode.attrs.treenode.expand)}, + style: {padding: '0px', + 'margin-right': '8px', + width: '24px', + height: '24px'}}, + m("i.material-icons", vnode.attrs.treenode.expand?"expand_more":"navigate_next") + ) + // Wenn der Trennode keine Kinder hat, dann soll kein "expand_more/expand_less"-Button + // angezeigt werden, allerdings soll durch 'margin-left' der entsprechende Platz des + // Buttons eingerückt werden, damit die Einrückungs-Level auch bei "kinderlosen" Treenodes + // denen der Trennodes mit Kindern entsprechen + : vnode.attrs.level == 0 + ? null // Wenn der kinderlos Treenode allerdings sirekt auf Level 0 ist, soll nocht + // eingerückt werden, da es bei Level 0 keine Eltern-Nodes mit Kindern und + // demzufolge mit "expand_more/expand_less"-Button gibt. + : m( + "span", { + style: {'margin-left': '24px', + 'margin-right': '8px'} + } + ), + // Nachdem die einrückung auf Grund der "hasChildren"-Abfrage erledigt ist, wird der Inhalt + // der ersten Spalte des Column-Mappers angezeigt + vnode.attrs.columnmapper1(vnode.attrs.treenode).splice(0,1) + ) + ), + // Dann werden die übrigen Spalten ganz normal als td-Elemente eingefügt + vnode.attrs.columnmapper1(vnode.attrs.treenode).splice(1).map((column) => { + return m("td", column) + }) + ), + // Falls der Trennode Kinder hat und expandiert werden soll, wird rekursiv die Trennode-Komponente für + // die Child-Nodes aufgerufen, ansonsten wir null zurückgegeben und die Einrückung hört auf + vnode.attrs.treenode.hasChildren && vnode.attrs.treenode.expand + ? vnode.attrs.treenode.children.map((tn: iTreenode, z) => { + return m(Treerow, { + treenode: tn, + level: vnode.attrs.level + 1, + columnmapper1: vnode.attrs.columnmapper1, + onRowMarked: vnode.attrs.onRowMarked, + isEven: ((z % 2) == 0) + } as iTreerow) + }) + : null + ] + } + } +} + +export interface iTopbarButton { + onclick: Function + tooltip?: string +} +export const TopbarButton: m.ClosureComponent = () => { + return { + view: (vnode) => { + return m("button.w3-button.w3-bar-item", { + style: {'min-width': '32px', height: '32px', padding: '4px'}, + onclick: vnode.attrs.onclick, + title: vnode.attrs.tooltip?vnode.attrs.tooltip:null + }, + vnode.children + ) + } + } +} + +/** Die Funktion kann aufgerufen werden, wenn eine Treenode-Struktur hinsichtlich + * "Expand more/less" geändert werden soll. + */ +export function changeTreenodes(treenodes: Treenode[], expand: boolean) { + treenodes.forEach((treenode: Treenode) => { + changeTreenode(treenode, expand) + if (treenode.hasChildren) { + changeTreenodes(treenode.children, expand) + } + }) +} +function changeTreenode(treenode: Treenode, expand: boolean) { + treenode.expand = expand +} + +interface iSearchfield { + setSearchvalue: (v:string) => void +} +const SearchField:m.ClosureComponent = (vnode) => { + let inputfield: HTMLInputElement|null = null + function oninput(e:Event) { + if (inputfield) { // ✅ Null-Check + let v:string = inputfield.value + vnode.attrs.setSearchvalue(v) + } + } + return { + view: (vnode) => { + return m("div", {style: {display: "flex", border: "1px solid black", margin: '1px'}}, + m("input.ggg-inputfocus", { + oncreate: (vnode) => { + inputfield = vnode.dom as HTMLInputElement + inputfield.focus() + }, + type:"text", + style: {flex: '1', border: 'none'}, oninput: oninput + }), + m("button.w3-button", { + style: {'min-width': '28px', height: '28px', padding: '4px', border: 'none'}, + onclick: () => { + if (inputfield) { // ✅ Null-Check + inputfield.value = "" + vnode.attrs.setSearchvalue("") + inputfield.focus() + } + }}, + m("i.material-icons", {style: {'font-size': 'medium'}}, "clear") + ) + ) + } + } +} \ No newline at end of file diff --git a/Client/Components/widgets/base.ts b/Client/Components/widgets/base.ts new file mode 100644 index 0000000..1494a9f --- /dev/null +++ b/Client/Components/widgets/base.ts @@ -0,0 +1,7 @@ +export class BaseView { + + getattr(vnode: any, attrname: string, defaultvalue: any) { + return vnode.attrs[attrname] === undefined ? defaultvalue : vnode.attrs[attrname] + } + +} \ No newline at end of file diff --git a/Client/Components/widgets/form.ts b/Client/Components/widgets/form.ts new file mode 100644 index 0000000..05ced96 --- /dev/null +++ b/Client/Components/widgets/form.ts @@ -0,0 +1,112 @@ +import m from "mithril" + +export interface iForm { + + onchanged?: (e:Event) => void + onsubmit: (form:HTMLFormElement) => boolean + oncancel: (e:Event) => void + showbuttons?: boolean + /** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */ + onformcreate?: (form:HTMLFormElement) => void +} + +export const Form:m.ClosureComponent = (vnode) => { + let form: HTMLFormElement + let isDirty: boolean = false + let showButtons: boolean = vnode.attrs.showbuttons == undefined ? true : vnode.attrs.showbuttons + function canCloseForm(typ: string = "") { + if (!isDirty) { + return true + } + let text = (typ == "reset") ? "zurücksetzen" : "beenden" + return confirm (`Das Formular enthält noch ungespeicherte Eingaben. Wollen Sie das Formular trotzdem ${text}?`) + } + function onchanged (e:Event) { + isDirty = true + if (vnode.attrs.onchanged != undefined) { + vnode.attrs.onchanged(e) + } + } + function submit (e:Event) { + e.preventDefault() + if (vnode.attrs.onsubmit(form) == true) { + isDirty = false + } + } + function registerFormElement (f:HTMLFormElement) { + if (vnode.attrs.onformcreate != undefined) { + vnode.attrs.onformcreate(f) + } + } + function cancel(e:Event) { + e.preventDefault() + if (canCloseForm() == true) { + isDirty = false + if (vnode.attrs.oncancel != undefined) { + vnode.attrs.oncancel(e) + } + else { + history.back() + } + } + else { + e['redraw'] = false + } + } + function reset(e:Event) { + if (canCloseForm("reset")) { + isDirty = false + // Hier passiert nichts. + // Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form + // neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja + // ohne submit noch nicht geändert wurde. + return + } + e['redraw'] = false + return + } + return { + onremove: (vnode) => { + let message = "remove" + if (isDirty) { + message += " isDirty" + } + console.log(message) + }, + view: (vnode) => { + return m("form", { + onreset: () => {console.log("reset")}, + onsubmit: submit, + oncreate: (vnode) => { + form = vnode.dom as HTMLFormElement + let elements = form.querySelectorAll("input,textarea") + for (let index = 0; index < elements.length; index++) { + const element = elements[index]; + element.addEventListener("change", onchanged) + } + registerFormElement(form) + } + }, + vnode.children, + showButtons == true ? + m("div.w3-bar.w3-padding-16",[ + m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, type: "submit"}, "Speichern"), + m("button.w3-button.w3-theme.w3-right.w3-mobile", {style: {margin: '0px 0px 2px 4px'}, onclick: cancel, type: "button"}, "Abbrechen"), + m("button.w3-button.w3-theme.w3-right.w3-mobile", {onclick: reset, type: "button"}, "Zurücksetzen") + ]) + : null + ) + } + } +} + +export interface iFormBorder { + maxwidth: string +} +export const FormBorder: m.Component = { + view: (vnode) => { + let mw:string = vnode.attrs.maxwidth === undefined ? "600px" : vnode.attrs.maxwidth + return m("div.w3-border.w3-border-theme.w3-container.w3-content.w3-margin-top", + {style: {'max-width': mw, 'box-shadow': '3px 3px 3px darkgrey'}}, vnode.children) + } + } \ No newline at end of file diff --git a/Client/Components/widgets/inputs.ts b/Client/Components/widgets/inputs.ts new file mode 100644 index 0000000..437fd12 --- /dev/null +++ b/Client/Components/widgets/inputs.ts @@ -0,0 +1,127 @@ +import m from "mithril" + +/** + * Der Vnode der Komponente 'Input' akzeptiert die folgenden Parameter: + * @property {string} name - Der Name des Feldes + * @property {any} value - der darzustellende Wert + * @property {string} type (optional) - der Type des Input Felds. Möglich sind alle Type des HTML-Elements 'Input' + * @property {string|number} width (optional) - die Breite des Input Felds. Möglich sind alle Längeneinheiten, die bom Width-Attribut möglich sind + * @property {number} min (optional) - + * @property {number} max (optional) - + * @property {number} step (optional) - + * @property {boolean} autofocus (optional) - + */ +export interface iInput { + name: string + value: any + type?: string + width?: string + min?: number + max?: number + step?: number + autofocus?: boolean + readonly?: boolean + required?:boolean + onchanged?: Function +} +/** + * Diese Komponente stellt ein Html-Input Element dar. Der darzustellende Wert wird einfach mit dem Attribut + * 'value' übergeben. Der eingegebene Wert muss dann später explizit aus Value abgefragt werden. Es findet also kein + * sutomatisches Binding statt. + */ +export const Input:m.Component = { + onupdate: (vnode) => { + if (vnode.attrs.autofocus != undefined && vnode.attrs.autofocus == true) { + let e: HTMLElement = vnode.dom as HTMLElement + e.focus() + } + }, + view: (vnode) => { + let attributes = {} + let width:number|string = '100%' + attributes['name'] = vnode.attrs.name + attributes['type'] = vnode.attrs.type == undefined ? 'text' : vnode.attrs.type + if (attributes['type'] == 'number') { + if (vnode.attrs.min != undefined) { + attributes['min'] = vnode.attrs.min + } + if (vnode.attrs.max != undefined) { + attributes['max'] = vnode.attrs.max + } + if (vnode.attrs.step != undefined) { + attributes['step'] = vnode.attrs.step + } + } + if (attributes['type'] == 'date') { + attributes['value'] = toDateInputValue(vnode.attrs.value) + } + else { + attributes['value'] = vnode.attrs.value + } + if (vnode.attrs.required != undefined && vnode.attrs.required == true) { + attributes['required'] = vnode.attrs.required + } + if (vnode.attrs.readonly != undefined && vnode.attrs.readonly == true) { + attributes['readonly'] = "true" + } + if (vnode.attrs.width != undefined) { + width = vnode.attrs.width + } + attributes['style'] = {'margin-bottom': '5px', 'width': width} + if (vnode.attrs.onchanged != undefined) { + attributes['onchange'] = vnode.attrs.onchanged + } + + + return m("input.w3-input.w3-border", attributes) + } +} + +export interface iTextarea { + name: string + value: any + cols?: number + rows?: number + autofocus?: boolean + readonly?: boolean +} + +/** + * Diese Komponente stellt ein Html-Textarea Element dar. Der darzustellende Wert wird einfach mit dem Attribut + * 'value' übergeben. Der eingegebene Wert muss dann später explizit aus Value abgefragt werden. Es findet also kein + * automatisches Binding statt. + */ +export const Textarea:m.Component = { + onupdate: (vnode) => { + if (vnode.attrs.autofocus != undefined && vnode.attrs.autofocus == true) { + let e: HTMLElement = vnode.dom as HTMLElement + e.focus() + } + }, + view: (vnode) => { + let attributes = {} + attributes['name'] = vnode.attrs.name + attributes['value'] = vnode.attrs.value + if (vnode.attrs.cols != undefined) { + attributes['cols'] = vnode.attrs.cols + } + if (vnode.attrs.rows != undefined) { + attributes['rows'] = vnode.attrs.rows + } + if (vnode.attrs.readonly != undefined && vnode.attrs.readonly == true) { + attributes['readonly'] = vnode.attrs.readonly + } + attributes['style'] = {'margin-bottom': '5px', "width": '100%'} + + return m("textarea.w3-input.w3-border.w3-mobile", attributes) + } +} + +export function toDateInputValue(date: Date): string { + console.log(date, typeof(date), (date instanceof Date)) + let result: string = null + if (date instanceof Date && date != null) { + result = [date.getFullYear(), (date.getMonth() + 1).toString().padStart(2, "0"), date.getDate().toString().padStart(2, "0")].join("-") + } + return result +} \ No newline at end of file diff --git a/Client/Components/widgets/tab_view.ts b/Client/Components/widgets/tab_view.ts new file mode 100644 index 0000000..3f31086 --- /dev/null +++ b/Client/Components/widgets/tab_view.ts @@ -0,0 +1,39 @@ +import m from "mithril" + + +interface iTabDef { + tabname: string + tabview: m.ComponentTypes +} + +type TabDef = [string, m.ComponentTypes] + +export interface iTabView { + tabviews: TabDef[] +} + +export const TabView: m.ClosureComponent = (vnode) => { + let tv = {} + vnode.attrs.tabviews.forEach((item: TabDef) => { + tv[item[0]] = item[1] + }) + + let currTab = "" + function changeview(e) { + currTab = e.target.value + } + return { + view: (vnode) => { + return m("div", {style: {display: 'grid', 'grid-template-rows': "auto 1fr", height: '100%'}}, [ + m("div.w3-bar.w3-theme.w3-border-bottom.w3-border-theme.w3-theme-action.w3-small", + Object.keys(tv).map((key) => { + if (currTab == "") {currTab = key} + let c = (key == currTab) ? ".w3-theme-light" : "" + return m("button.w3-bar-item.w3-button" + c, {value: key, onclick: changeview}, key) + }) + ), + m("div", m(tv[currTab])) + ]) + } + } +} \ No newline at end of file diff --git a/Client/index.ts b/Client/index.ts new file mode 100644 index 0000000..737dda8 --- /dev/null +++ b/Client/index.ts @@ -0,0 +1,149 @@ +import m from "mithril" +import {gridMountpoints, maingrid} from "./Components/mainlayout" +import {header, menubar, init_menustack, get_menustack, headerdata} from "./Components/header" +import { appmanagementpage } from "./Components/auth/appmanagementpage" +import { changepasswordscreen, loginscreen } from "./Components/auth/login" +import {kunde} from "./Components/kunde" +import { Projekt } from "./Components/projekt" + + +function healthcheck () { + m.request({ + method: "GET", + url: "/xhr/health-check"}) + .then((result) => { + data.success = result['result'] + data.time = result['time'] + data.server = result['server'] + data.projectname = result["projectname"] + data.clientappversion = result["clientappversion"] + }) + return +} + +function getClientappversion(): void { + //let version:string = document.querySelector('meta[name="clientappversion"]')['content'] + const meta = document.querySelector('meta[name="clientappversion"]'); + const version = meta?.content ?? ''; // leerer String als Fallback + console.log(version) + headerdata.version = version +} + +function check_currentuser (): boolean { + let result = true + const cu = sessionStorage.getItem("CurrentUser") + if (cu != null) { + headerdata.username = cu + } + else { + result = false + } + return result +} + +let data = { + success: "n/a", + time: "n/a", + server: "n/a", + projectname: "n/a", + clientappversion: "n/a" +} + +function logout() { + m.request({ + method: "GET", + url: "/auth/logout" + }) + .then((result) => { + console.log(result) + sessionStorage.removeItem("CurrentUser"); + sessionStorage.removeItem("CurrentUserIsSysAdmin"); + headerdata.username = "Nicht angemeldet" + if (result['logout'] == true) {m.route.set("/blankpage")} + }) + .finally(() => { + location.href = "/index.html" + }) +} + +const hello: m.Component = { + oninit: (vnode) => { + healthcheck() + }, + view: (vnode) => { + return m("div", + m("h1", "Health-Check Python App-Server"), + m(hello2), + m(knopf) + ) + } +} + +const hello2: m.Component = { + view: (vnode) => { + return m("ul", + m("li", "Result: " + data.success), + m("li", data.time), + m("li", data.server), + m("li", data.projectname), + m("li", data.clientappversion), + ) + } +} + +const knopf: m.Component = { + view: (vnode) => { + return m("button", {onclick: healthcheck}, "Aktualisieren") + } +} +const menu: m.Component = { + view: (vnode) => { + return m(menubar, [ + m("a.w3-button.w3-bar-item", {href: "#!/kunde"}, "Kunden"), + m("a.w3-button.w3-bar-item", {href: "#!/projekt"}, "Projekte"), + m("a.w3-button.w3-bar-item", {href: "#!/routename"}, "Gesprächsnotizen"), + // m("a.w3-button.w3-bar-item", {onclick: healthcheck}, "Aktualisieren"), + m("div.w3-dropdown-hover", + m("button.w3-button", "Optionen"), + m("div.w3-dropdown-content.w3-bar-block.w3-card-4", + m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Tags"), + m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"), + m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"), + ) + ), + m("div.w3-button.w3-right", {onclick: logout}, "Logout"), + m("div.w3-dropdown-hover.w3-right", + m("button.w3-button", "System"), + m("div.w3-dropdown-content.w3-bar-block.w3-card-4", + m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"), + m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"), + m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"), + ) + ) + ]) + } +} + +let mountpoint = document.getElementById("body") + +m.mount(mountpoint, maingrid) +m.mount(gridMountpoints.header, header) +m.mount(gridMountpoints.main, hello) + +init_menustack(gridMountpoints.menubar) +get_menustack().push(menu) + +getClientappversion() + +if (check_currentuser() == false) { + m.route.set("/login") +} + +m.route(gridMountpoints.main, "/blankpage", { + "/blankpage": hello, + "/usermanagement": appmanagementpage, + "/changepassword": changepasswordscreen, + "/login": loginscreen, + "/kunde": kunde, + "/projekt": Projekt +}) \ No newline at end of file diff --git a/Client/inireader.mjs b/Client/inireader.mjs new file mode 100644 index 0000000..c8ef556 --- /dev/null +++ b/Client/inireader.mjs @@ -0,0 +1,42 @@ + +import { readFileSync } from 'fs'; + +var result = "" + +// https://stackoverflow.com/questions/55409195/reading-ini-file-using-javascript +function parseINIString(data){ + var regex = { + section: /^\s*\[\s*([^\]]*)\s*\]\s*$/, + param: /^\s*([^=]+?)\s*=\s*(.*?)\s*$/, + comment: /^\s*;.*$/ + }; + var value = {}; + var lines = data.split(/[\r\n]+/); + var section = null; + lines.forEach(function(line){ + if(regex.comment.test(line)){ + return; + }else if(regex.param.test(line)){ + var match = line.match(regex.param); + if(section){ + value[section][match[1]] = match[2]; + }else{ + value[match[1]] = match[2]; + } + }else if(regex.section.test(line)){ + var match = line.match(regex.section); + value[match[1]] = {}; + section = match[1]; + }else if(line.length == 0 && section){ + section = null; + }; + }); + return value; +} + +export function getIniValue(value){ + return result["APP"][value] +} + +var inifile = readFileSync("../App_Server/root/config.ini").toString() +result = parseINIString(inifile) diff --git a/Client/package-lock.json b/Client/package-lock.json new file mode 100644 index 0000000..4cb290c --- /dev/null +++ b/Client/package-lock.json @@ -0,0 +1,1299 @@ +{ + "name": "Client", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "mithril": "^2.2.2" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "@types/mithril": "^2.2.7", + "rollup": "^4.21.2", + "typescript": "^6.0.3" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.3.tgz", + "integrity": "sha512-2BJcolt43MY+y5Tz47djHkodCC3c1VKVrBDKpVqHKpQ9z9S158kCCqB8NF6/gzxLdNlYW9abB3Ibh+kOWLp8KQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^10.4.1", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/mithril": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.2.8.tgz", + "integrity": "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mithril": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/mithril/-/mithril-2.3.8.tgz", + "integrity": "sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ==" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/Client/package.json b/Client/package.json new file mode 100644 index 0000000..ab198a1 --- /dev/null +++ b/Client/package.json @@ -0,0 +1,13 @@ +{ + "dependencies": { + "mithril": "^2.2.2" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "@types/mithril": "^2.2.7", + "rollup": "^4.21.2", + "typescript": "^6.0.3" + } +} diff --git a/Client/rollup.config.mjs b/Client/rollup.config.mjs new file mode 100644 index 0000000..020ae3a --- /dev/null +++ b/Client/rollup.config.mjs @@ -0,0 +1,42 @@ +// Die Config-Datei muss ab Rollup Version 3 die Endung .mjs haben. (s. Rollup Dokumentation "Migration to Rollup 3") +import {nodeResolve} from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import terser from '@rollup/plugin-terser'; +// import json from '@rollup/plugin-json'; +import {getIniValue} from "./inireader.mjs" + +var version = getIniValue("version") +var projectname = getIniValue("projectname") + +export default { + input: './tsout/index.js', + output: [ + { + file: `/usr/opt/pythonapp/NGINX_Server/html/static/${projectname}_${version}.js`, + format: 'iife', + name: `${projectname}` + }, + { + file: `/usr/opt/pythonapp/NGINX_Server/html/static/${projectname}_${version}.min.js`, + format: 'iife', + name: `${projectname}`, + plugins: [ + terser() + ] + } + ], + plugins: [ + nodeResolve({ + // Der folgende Eintrag ist erforderlich, weil das tsout-Verzeichnis und somit das Input-Verzeichnis für Rollup, + // welches die Typescript-Kompilierten Programme enthält, nicht als Unterverzeichnis des Projekt-Verzeichnisses + // (...\UWSGI-Server\vassals\timesheet\client\src) angelegt ist, sondern parallel dazu. + // Ohne den folgenden Eintrag findet Rollup dann nicht das "Mithril"-Modul und es kommt zu Fehlern ("global nicht gefunden" usw.) + // Zusätzlich verwirrend war, dass der Fehler nicht auftrat, wenn das commonjs-Plugin mit der Version 21 eingesetzt war. + // Erst nachdem ich herausgefunden hatte, dass der nachfolgende Eintrag im Node-Resolve-Plugin den Fehler behob, konnte + // ich das commenjs-Plugin in der aktuellsten Version einsetzten. + modulePaths: ['node_modules'], + }), + commonjs() + // json() + ] + }; \ No newline at end of file diff --git a/Client/tsconfig.json b/Client/tsconfig.json new file mode 100644 index 0000000..3302fad --- /dev/null +++ b/Client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "allowJs": false, + "outDir": "./tsout", + "module": "ESNext", + "target": "ES2019", + "allowSyntheticDefaultImports": true, + "strict": false + } +} \ No newline at end of file diff --git a/Dokumentationen/Anpassungen an DevEnvCreator-Strukturen.bak b/Dokumentationen/Anpassungen an DevEnvCreator-Strukturen.bak new file mode 100644 index 0000000..39c54c0 Binary files /dev/null and b/Dokumentationen/Anpassungen an DevEnvCreator-Strukturen.bak differ diff --git a/Dokumentationen/Anpassungen an DevEnvCreator-Strukturen.cts b/Dokumentationen/Anpassungen an DevEnvCreator-Strukturen.cts new file mode 100644 index 0000000..50fd833 Binary files /dev/null and b/Dokumentationen/Anpassungen an DevEnvCreator-Strukturen.cts differ diff --git a/NGINX_Server/Dockerfile b/NGINX_Server/Dockerfile new file mode 100644 index 0000000..c4da972 --- /dev/null +++ b/NGINX_Server/Dockerfile @@ -0,0 +1,4 @@ +ARG NGINX_VERSION +FROM nginx:${NGINX_VERSION} +RUN ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime +RUN mkdir /etc/certs \ No newline at end of file diff --git a/NGINX_Server/certs/wildcard.mylocal.crt b/NGINX_Server/certs/wildcard.mylocal.crt new file mode 100644 index 0000000..631b1aa --- /dev/null +++ b/NGINX_Server/certs/wildcard.mylocal.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmjCCAoKgAwIBAgIUIDBuHg1o+s/XylnF+ZGrpcD1PDIwDQYJKoZIhvcNAQEL +BQAwWzELMAkGA1UEBhMCREUxDDAKBgNVBAgMA05SVzERMA8GA1UEBwwIU2llZ2J1 +cmcxFjAUBgNVBAoMDVNjaG9zY2hpc0NsdWIxEzARBgNVBAMMCm15bG9jYWwuZGUw +HhcNMjAwNTIxMTAzNDEzWhcNMzAwNTE5MTAzNDEzWjBdMQswCQYDVQQGEwJERTEM +MAoGA1UECAwDTlJXMREwDwYDVQQHDAhTaWVnYnVyZzEWMBQGA1UECgwNU2Nob3Nj +aGlzQ2x1YjEVMBMGA1UEAwwMKi5teWxvY2FsLmRlMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAoSgsxm7iNqNK6MVwXebTCUDCW3hxqd4bmG/HR9VxfIp8 +eQDBbJNe/q+sOmEG2D6CNNPMuOlerqGJQnqzG83nNhEdVjMFdkgIEs2XjS+CgNjX +P442tYAibLxX/9UBQCYODA1RNv65dRNPxMCRQrMke2b6nU2+5EwBTFoxIQ5otarN +PPDRu19u5yqJQwxcC6zZ3sNmq/jOLkTy9g+JDrMRydtArV6Y6LUsZBTUPT5cKO+m +fQGe7D7b5+YBdu9TI4YpzG6/7Z3dlEehBJiWpLSdKD2FlN/jidiNr/uslpTIxdEV +mK7342LZuXOlwEdVSsP8lafnzV3WtH/nRZxZ7rpDNwIDAQABo1QwUjAfBgNVHSME +GDAWgBRyi8YbpjAj5TN2v2vRGfVcnM4kBjAJBgNVHRMEAjAAMAsGA1UdDwQEAwIE +8DAXBgNVHREEEDAOggwqLm15bG9jYWwuZGUwDQYJKoZIhvcNAQELBQADggEBAAdz +t0/rhsV+91BfkO1yUUm0m4H5vLsY4YLjoxhXqGRGwfp7CAV7n5iS4akMzVrs+y+4 +Q8AbVeGsrv3AHWZ+ZKL64AGjFnTVXEeI1YN6cdwPkAvmnLi4QuXCRlJdDTEzJ6pf +4mHgHWNIT3H3GkGGLJLrBdz1NBn4QHt+7UHkBtYcWiGB9JOp0ungEdNmzA9fOPNo +eLtkKe1QuvlafUhKxGmnIiQyDZ+DI8EvDAX22qakcLyNeP5gxptDEf6nx2GUupVt +A+qxI+Rfg64KtiK6J2lVKbu6/fkvh82Dtv7aHiP7jXQSLkxaDyfZFQY7ZAEwyOS9 +NqTHZcSqDB4E8TuDu2Q= +-----END CERTIFICATE----- diff --git a/NGINX_Server/certs/wildcard.mylocal.key b/NGINX_Server/certs/wildcard.mylocal.key new file mode 100644 index 0000000..2153e55 --- /dev/null +++ b/NGINX_Server/certs/wildcard.mylocal.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQChKCzGbuI2o0ro +xXBd5tMJQMJbeHGp3huYb8dH1XF8inx5AMFsk17+r6w6YQbYPoI008y46V6uoYlC +erMbzec2ER1WMwV2SAgSzZeNL4KA2Nc/jja1gCJsvFf/1QFAJg4MDVE2/rl1E0/E +wJFCsyR7ZvqdTb7kTAFMWjEhDmi1qs088NG7X27nKolDDFwLrNnew2ar+M4uRPL2 +D4kOsxHJ20CtXpjotSxkFNQ9Plwo76Z9AZ7sPtvn5gF271MjhinMbr/tnd2UR6EE +mJaktJ0oPYWU3+OJ2I2v+6yWlMjF0RWYrvfjYtm5c6XAR1VKw/yVp+fNXda0f+dF +nFnuukM3AgMBAAECggEAHvMCNlGRLAEMH8vbGo17tekz6jElUwQOtKWy1b7N/qbx +5KMagmmC8tJ19vgvQlQu2UpbpJ9cyZjILXY2834Fi0KaKbW+tMp8km/dTQX0wB3k +qJX9N7oV4mABXmZfyRwF0pa0JCWjJDLDPPvmabLRrMlIE/GcWWenUCx6Buknfsuy +bT0z0SG3b9fiQYp5o+vUknsHeW6NOJP9cvS6yLw8nH2keO97ldqQw074xs0IFlHo +5Vecoeuv+Kr+EPxJpWovWHRVoJrXDsH95wQ+eqR15H9PMrzWKE5ilzT54/V3tJ+V +840865hndaSF0LaF1huNAc088FyrgLM6MnOTFc5nAQKBgQDTxAI6cwsLfFwyed1f +XRAWj6XBlCwAdh+rIzz2WTS5XQnN0bBof5cjY3pgZW3guG2cKs5TtW4iZKYvgQel +nmO+DTiIMBJFmnTeT7PLdszvbtgncrABc5h92BRFuCdJOTyxVyo+RH4mUWvuUgzl +fOzGCMgTDj997ayCOtJQ9V38QQKBgQDC0eguxL0rNMvv1hBz8ri9pe9u5c252C1L +QxiWv0bUkOVxldVk5sZwNdvntGLKX0J5JiIrAUYWyPSWcizxdj+8XKZ+Qrj1d4tQ +Fxex27ZINTOprPI8DFAxk1N3coQ0XtQ1ANAYgqzTVt0N2ceV6JR5mrNaRFAuxFRV +GiFYEZ3BdwKBgH1aR59lviH8aW8PqDTtF7rjKVFfbnSPd8uMPmftAkK/ijAlcfFJ +paOzHRu2rsYZJfkNdbkjaqA9SApSrL4i9hPoK5CiNA6zkX6PW3STTFhnGtivEPSr +ApdrswRaMBcuJHktibn2PV5dUDBwrWWi/11ykUyGYJsgd79XJmv58rqBAoGBAJ7k +ajSyuwJhJi9u7K5UeT4b9wMApGB9ortudsCEMrWtk6SnKosH+TKNitQYeDZLqUB7 +zIQ8nrh4n5uQdX1eDy+GdTgnHQj+vf3NR8MEyk9sLL2nU02MUV+NpBaYrKC18udj +yVUkRuZ7QwFH7JMkROxFMyBc9PSfpZRfInDctUiTAoGBAK5NmzGicDEqcyD4U2Dx +nxc5t0KP2DtqfMDWAL/hTZsV1hCq1QsQ5jmzbVq3tM1BUDDMoNwxm47ycQaD8vhb +SByA8660UEmMYX3C1Dx6N+N768ca6GSM1sOGCngl9ldObhdX0xIUYZXcn33EIq4e +/NhzHj/po4W1yBVlRUhaVPT2 +-----END PRIVATE KEY----- diff --git a/NGINX_Server/configtemplates/kbp_crm.conf.template b/NGINX_Server/configtemplates/kbp_crm.conf.template new file mode 100644 index 0000000..99bdb89 --- /dev/null +++ b/NGINX_Server/configtemplates/kbp_crm.conf.template @@ -0,0 +1,38 @@ +ssl_certificate /etc/certs/${NGINX_CERT}; +ssl_certificate_key /etc/certs/${NGINX_CERT_KEY}; + +# Zunächst wird ein Default-Server definiert. +# Das Standardverhalten von NGINX ist nämlich wie folgt: +# Wenn im Request ein Server-Name reinkommt und NGINX keinen Server-Block findet, in dem dieser +# Servername definiert wird, wird einfach der erste definierte Serverblock genommen. +# Dadurch, dass ein expliziter Default-Server definiert wird, wird dieser verwendet. +# Falls jetzt also ein Servername übergeben wird, den es nicht gibt, gibt NGINX den Error-Code 404 zurück +server { + listen 4430 ssl default_server; + return 404; +} + +server { + listen 4430 ssl; + server_name ${NGINX_SERVER_NAME}; + resolver 127.0.0.11 valid=30s; # notwendig, damit der Upstream (s.u.) nicht direkt den nginx-Server stoppt, falls pythonapp noch nicht soweit ist + # https://sandro-keil.de/blog/let-nginx-start-if-upstream-host-is-unavailable-or-down/ + + location /static { + root /usr/share/nginx/html; + } + + location / { + # Variable muss benutzt werden (s.o. bei resolver Direktive) + set $upstream pythonapp:8080; + proxy_pass http://$upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /health-check { + add_header Content-Type text/plain; + return 200 "success $time_iso8601"; + } +} \ No newline at end of file diff --git a/NGINX_Server/html/static/Google_Symbols.woff2 b/NGINX_Server/html/static/Google_Symbols.woff2 new file mode 100644 index 0000000..2116f5e Binary files /dev/null and b/NGINX_Server/html/static/Google_Symbols.woff2 differ diff --git a/NGINX_Server/html/static/KBplus-Logo.png b/NGINX_Server/html/static/KBplus-Logo.png new file mode 100644 index 0000000..2221b70 Binary files /dev/null and b/NGINX_Server/html/static/KBplus-Logo.png differ diff --git a/NGINX_Server/html/static/app-theme.css b/NGINX_Server/html/static/app-theme.css new file mode 100644 index 0000000..ff934d6 --- /dev/null +++ b/NGINX_Server/html/static/app-theme.css @@ -0,0 +1,22 @@ +.w3-theme-l5 {color:#000 !important; background-color:#f9f9f9 !important} +.w3-theme-l4 {color:#000 !important; background-color:#ececec !important} +.w3-theme-l3 {color:#000 !important; background-color:#d8d8d8 !important} +.w3-theme-l2 {color:#000 !important; background-color:#c5c5c5 !important} +.w3-theme-l1 {color:#000 !important; background-color:#b1b1b1 !important} +.w3-theme-d1 {color:#fff !important; background-color:#8e8e8e !important} +.w3-theme-d2 {color:#fff !important; background-color:#7e7e7e !important} +.w3-theme-d3 {color:#fff !important; background-color:#6f6f6f !important} +.w3-theme-d4 {color:#fff !important; background-color:#5f5f5f !important} +.w3-theme-d5 {color:#fff !important; background-color:#4f4f4f !important} + +.w3-theme-light {color:#000 !important; background-color:#f9f9f9 !important} +.w3-theme-dark {color:#fff !important; background-color:#4f4f4f !important} +.w3-theme-action {color:#fff !important; background-color:#4f4f4f !important} + +.w3-theme {color:#fff !important; background-color:#9e9e9e !important} +.w3-text-theme {color:#9e9e9e !important} +.w3-border-theme {border-color:#9e9e9e !important} + +.w3-hover-theme:hover {color:#fff !important; background-color:#9e9e9e !important} +.w3-hover-text-theme:hover {color:#9e9e9e !important} +.w3-hover-border-theme:hover {border-color:#9e9e9e !important} \ No newline at end of file diff --git a/NGINX_Server/html/static/symbols.css b/NGINX_Server/html/static/symbols.css new file mode 100644 index 0000000..79cd371 --- /dev/null +++ b/NGINX_Server/html/static/symbols.css @@ -0,0 +1,23 @@ +/* fallback */ +@font-face { + font-family: 'Material Symbols Outlined'; + font-style: normal; + font-weight: 400; + src: url(Google_Symbols.woff2) format('woff2'); + } + + .material-icons { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; + } \ No newline at end of file diff --git a/NGINX_Server/html/static/w3.css b/NGINX_Server/html/static/w3.css new file mode 100644 index 0000000..fd89e8d --- /dev/null +++ b/NGINX_Server/html/static/w3.css @@ -0,0 +1,235 @@ +/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ +html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} +/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ +html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} +article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item} +audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline} +audio:not([controls]){display:none;height:0}[hidden],template{display:none} +a{background-color:transparent}a:active,a:hover{outline-width:0} +abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted} +b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000} +small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline} +sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none} +code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible} +button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold} +button,input{overflow:visible}button,select{text-transform:none} +button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button} +button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0} +button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText} +fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em} +legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto} +[type=checkbox],[type=radio]{padding:0} +[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto} +[type=search]{-webkit-appearance:textfield;outline-offset:-2px} +[type=search]::-webkit-search-decoration{-webkit-appearance:none} +::-webkit-file-upload-button{-webkit-appearance:button;font:inherit} +/* End extract */ +html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden} +h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px} +.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace} +h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px} +hr{border:0;border-top:1px solid #eee;margin:20px 0} +.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit} +.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc} +.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1} +.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1} +.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center} +.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top} +.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px} +.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap} +.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)} +.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none} +.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none} +.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none} +.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%} +.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none} +.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block} +.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s} +.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%} +.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc} +.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer} +.w3-dropdown-hover:hover .w3-dropdown-content{display:block} +.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000} +.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000} +.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1} +.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px} +.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto} +.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%} +.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%} +.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px} +.w3-main,#main{transition:margin-left .4s} +.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)} +.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px} +.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto} +.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0} +.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left} +.w3-bar .w3-button{white-space:normal} +.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0} +.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%} +.w3-responsive{display:block;overflow-x:auto} +.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before, +.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both} +.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%} +.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%} +.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%} +.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%} +@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%} +.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%} +.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}} +@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%} +.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%} +.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}} +.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px} +.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px} +.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell} +.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom} +.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important} +@media (max-width:1205px){.w3-auto{max-width:95%}} +@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px} +.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative} +.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center} +.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}} +@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}} +@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}} +@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}} +@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}} +.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0} +.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2} +.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0} +.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0} +.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)} +.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)} +.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)} +.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)} +.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)} +.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none} +.w3-display-position{position:absolute} +.w3-circle{border-radius:50%} +.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} +.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} +.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} +.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} +.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} +.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} +.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)} +.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)} +.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}} +.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}} +.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}} +.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}} +.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}} +.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}} +.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}} +.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}} +.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important} +.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1} +.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75} +.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)} +.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)} +.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)} +.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important} +.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important} +.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important} +.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important} +.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important} +.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important} +.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important} +.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important} +.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important} +.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important} +.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important} +.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important} +.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important} +.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important} +.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important} +.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important} +.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important} +.w3-left{float:left!important}.w3-right{float:right!important} +.w3-button:hover{color:#000!important;background-color:#ccc!important} +.w3-transparent,.w3-hover-none:hover{background-color:transparent!important} +.w3-hover-none:hover{box-shadow:none!important} +/* Colors */ +.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} +.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} +.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important} +.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important} +.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important} +.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important} +.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important} +.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important} +.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important} +.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important} +.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important} +.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important} +.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important} +.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important} +.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important} +.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important} +.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important} +.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important} +.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important} +.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important} +.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important} +.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important} +.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important} +.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} +.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} +.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} +.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} +.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} +.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} +.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important} +.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important} +.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important} +.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important} +.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important} +.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important} +.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important} +.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important} +.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important} +.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important} +.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important} +.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important} +.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important} +.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important} +.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important} +.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important} +.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important} +.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important} +.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important} +.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important} +.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important} +.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important} +.w3-text-white,.w3-hover-text-white:hover{color:#fff!important} +.w3-text-black,.w3-hover-text-black:hover{color:#000!important} +.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important} +.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important} +.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important} +.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important} +.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important} +.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important} +.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important} +.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important} +.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important} +.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important} +.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important} +.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important} +.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important} +.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important} +.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important} +.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important} +.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important} +.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important} +.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important} +.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important} +.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important} +.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important} +.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important} +.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important} +.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important} +.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important} +.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important} +.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important} +.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important} +.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important} +.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} \ No newline at end of file diff --git a/docker-compose.base.yaml b/docker-compose.base.yaml new file mode 100644 index 0000000..2142ece --- /dev/null +++ b/docker-compose.base.yaml @@ -0,0 +1,32 @@ +name: kbpbase +services: + nginx-proxy: + build: + context: NGINX_Server + args: # Übergibt die ARGS an das dockerfile + NGINX_VERSION: ${NGINX_VERSION} + + ports: + - ${NGINX_HOST_PORT}:4430 + volumes: + - ${NGINX_STATIC_DIR}:/usr/share/nginx/html:ro + - ${NGINX_CONFIG_TEMPLATE_DIR}:/etc/nginx/templates + - ${NGINX_CERTS}:/etc/certs + environment: + - NGINX_CERT=${NGINX_CERT} + - NGINX_CERT_KEY=${NGINX_CERT_KEY} + - NGINX_SERVER_NAME=${NGINX_SERVER_NAME} + pythonapp: + build: + context: App_Server + # target: kommt aus dem prod oder dev dockerfile + args: # Übergibt die ARGS an das dockerfile + PYTHON_VERSION: ${PYTHON_VERSION} + volumes: + #- ${PYTHON_APP_DIR}:/usr/opt/pythonapp/root + - ${PYTHON_DB_DIR}:/usr/var/pythonappdb + - python-venv:/usr/opt/pythonapp/env + # - ${PYTHON_ENV_DIR}:/usr/opt/pythonapp/env + +volumes: + python-venv: \ No newline at end of file diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..d139892 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,19 @@ +name: kbpdev +services: + pythonapp: + extends: + file: docker-compose.base.yaml + service: pythonapp + build: + target: dev + args: # Übergibt die ARGS an das dockerfile + # PYTHON_VERSION: ${PYTHON_VERSION} + NODE_VERSION: ${NODE_VERSION:-18} + NPM_VERSION: ${NPM_VERSION:-8} + volumes: + - ${PYTHON_DEV_DIR}:/usr/opt/pythonapp + # - ${TYPESCRIPT_SRC_DIR}:/usr/opt/typescript + - node-modules:/usr/opt/typescript/node_modules + +volumes: + node-modules: \ No newline at end of file diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..381dc04 --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,10 @@ +name: kbpprod +services: + pythonapp: + extends: + file: docker-compose.base.yaml + service: pythonapp + build: + target: prod + volumes: + - ${PYTHON_APP_DIR}:/usr/opt/pythonapp \ No newline at end of file diff --git a/docker-compose.save.yaml b/docker-compose.save.yaml new file mode 100644 index 0000000..2a9d2b1 --- /dev/null +++ b/docker-compose.save.yaml @@ -0,0 +1,26 @@ +services: + nginx-proxy: + build: + context: NGINX_Server + args: # Übergibt die ARGS an das dockerfile + NGINX_VERSION: ${NGINX_VERSION} + + ports: + - ${NGINX_HOST_PORT}:4430 + volumes: + - ${NGINX_STATIC_DIR}:/usr/share/nginx/html:ro + - ${NGINX_CONFIG_TEMPLATE_DIR}:/etc/nginx/templates + - ${NGINX_CERTS}:/etc/certs + environment: + - NGINX_CERT=${NGINX_CERT} + - NGINX_CERT_KEY=${NGINX_CERT_KEY} + - NGINX_SERVER_NAME=${NGINX_SERVER_NAME} + pythonapp: + build: + context: App_Server + args: # Übergibt die ARGS an das dockerfile + PYTHON_VERSION: ${PYTHON_VERSION} + volumes: + - ${PYTHON_APP_DIR}:/usr/opt/pythonapp/root + - ${PYTHON_DB_DIR}:/usr/var/pythonappdb + # - ${PYTHON_ENV_DIR}:/usr/opt/pythonapp/env \ No newline at end of file