initial commit

This commit is contained in:
2025-06-03 14:01:08 +00:00
commit 6ab9ceed4b
50 changed files with 2253 additions and 0 deletions

42
server/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# server/Dockerfile
# Produktions-Dockerfile für die Flask-API mit Gunicorn
# --- Basisimage ---
FROM python:3.13-slim
# --- Arbeitsverzeichnis ---
WORKDIR /app
# --- Systemabhängigkeiten für MariaDB-Client & Locale ---
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libmariadb-dev-compat libmariadb-dev locales \
&& rm -rf /var/lib/apt/lists/*
# --- Locale konfigurieren ---
RUN sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen
ENV LANG=de_DE.UTF-8 \
LC_ALL=de_DE.UTF-8
# --- Python-Abhängigkeiten ---
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# --- Applikationscode ---
COPY server/ /app
# --- Non-Root User anlegen und Rechte setzen ---
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
--shell /bin/bash --create-home infoscreen_taa \
&& chown -R infoscreen_taa:infoscreen_taa /app
USER infoscreen_taa
# --- Port für die API exposed ---
EXPOSE 8000
# --- Startbefehl für Gunicorn ---
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "wsgi:app"]

46
server/Dockerfile.dev Normal file
View File

@@ -0,0 +1,46 @@
# Datei: server/Dockerfile.dev
# Entwicklungs-Dockerfile für die API (Flask + SQLAlchemy)
FROM python:3.13-slim
# Build args für UID/GID
ARG USER_ID=1000
ARG GROUP_ID=1000
# Erstelle non-root User
RUN apt-get update \
&& apt-get install -y --no-install-recommends locales curl \
&& groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
ENV LANG=de_DE.UTF-8 \
LANGUAGE=de_DE:de \
LC_ALL=de_DE.UTF-8
# Arbeitsverzeichnis
WORKDIR /app
# Kopiere nur Requirements für schnellen Rebuild
COPY requirements.txt ./
COPY requirements-dev.txt ./
# Installiere Python-Abhängigkeiten (Prod + Dev)
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir -r requirements-dev.txt
# Expose Ports für Flask API
EXPOSE 8000
# Setze Env für Dev
ENV FLASK_ENV=development
ENV ENV_FILE=.env
# Wechsle zum non-root User
USER infoscreen_taa
# Default Command: Flask Development Server
CMD ["flask", "run", "--host=0.0.0.0", "--port=8000"]

0
server/__init__.py Normal file
View File

67
server/init_database.py Normal file
View File

@@ -0,0 +1,67 @@
from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, func, text
from sqlalchemy.orm import sessionmaker, declarative_base
import enum
import bcrypt
# Basis-Klasse für unsere Modelle
Base = declarative_base()
# Enum zur Definition der möglichen Rollen
class UserRole(enum.Enum):
user = "user"
admin = "admin"
superadmin = "superadmin"
# Definition des User Models (Tabelle)
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(60), nullable=False) # bcrypt erzeugt einen 60-Zeichen-Hash
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
created_at = Column(TIMESTAMP, server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
# Definition des Client Models (Tabelle)
class Client(Base):
__tablename__ = 'clients'
# Die UUID wird vom Client erzeugt und muss daher übermittelt werden (kein Default)
uuid = Column(String(36), primary_key=True, nullable=False)
# Der Hardware-Hash wird ebenfalls direkt vom Client geliefert
hardware_hash = Column(String(64), nullable=False)
# Spalte für den Standort, der vom Benutzer bei der Anmeldung eingegeben wird
location = Column(String(100), nullable=True)
# Spalte für die IP-Adresse, die vom Server beim Kontakt ermittelt wird
ip_address = Column(String(45), nullable=True)
# Speicherung des Registrierungszeitpunkts, wird automatisch gesetzt
registration_time = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False)
# Speicherung des Zeitpunkts der letzten Rückmeldung (Alive-Signal)
last_alive = Column(
TIMESTAMP,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
nullable=False
)
def main():
# Zuerst Verbindung zur MariaDB ohne spezifische Datenbank, um die Datenbank anzulegen.
admin_connection_str = 'mysql+pymysql://infoscreen_admin:KqtpM7wmNd$M1Da&mFKs@infoscreen-db/infoscreen_by_taa'
admin_engine = create_engine(admin_connection_str, echo=True)
# Datenbank "infoscreen_by_taa" anlegen, falls sie noch nicht existiert.
with admin_engine.connect() as conn:
conn.execute(text("CREATE DATABASE IF NOT EXISTS infoscreen_by_taa CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))
# Verbindung zur spezifischen Datenbank herstellen.
db_connection_str = 'mysql+pymysql://infoscreen_admin:KqtpM7wmNd$M1Da&mFKs@infoscreen-db/infoscreen_by_taa'
engine = create_engine(db_connection_str, echo=True)
# Erstelle die Tabellen 'users' und 'clients'
Base.metadata.create_all(engine)
print("Die Tabellen 'users' und 'clients' wurden in der Datenbank 'infoscreen_by_taa' erstellt.")
if __name__ == '__main__':
main()

57
server/init_db.py Normal file
View File

@@ -0,0 +1,57 @@
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from models import Base, User, UserRole
import os
from dotenv import load_dotenv
import bcrypt
# .env laden
load_dotenv()
# Konfiguration aus .env
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_HOST = os.getenv("DB_HOST")
DB_NAME = os.getenv("DB_NAME")
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME")
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD")
# Erst ohne DB verbinden, um sie ggf. zu erstellen
admin_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/"
admin_engine = create_engine(admin_conn_str, echo=True)
with admin_engine.connect() as conn:
conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))
# Jetzt mit Datenbank verbinden
db_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
engine = create_engine(db_conn_str, echo=True)
# Tabellen anlegen
Base.metadata.create_all(engine)
# Session erstellen
Session = sessionmaker(bind=engine)
session = Session()
# Prüfen, ob der User bereits existiert
existing_user = session.query(User).filter_by(username=DEFAULT_ADMIN_USERNAME).first()
if not existing_user:
# Passwort hashen
hashed_pw = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
# Neuen User anlegen
admin_user = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed_pw.decode('utf-8'),
role=UserRole.admin
)
session.add(admin_user)
session.commit()
print(f"Admin-User '{DEFAULT_ADMIN_USERNAME}' wurde erfolgreich angelegt.")
else:
print(f"Admin-User '{DEFAULT_ADMIN_USERNAME}' existiert bereits.")
session.close()

36
server/init_mariadb.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlalchemy import create_engine, text
import os
from dotenv import load_dotenv
# .env-Datei laden
load_dotenv()
# Schritt 1: Verbindung zur MariaDB herstellen, OHNE eine bestimmte Datenbank
DATABASE_URL = f"mysql+pymysql://root:{os.getenv('DB_ROOT_PASSWORD')}@{os.getenv('DB_HOST')}:3306"
engine = create_engine(DATABASE_URL, isolation_level="AUTOCOMMIT", echo=True)
db_name = os.getenv("DB_NAME")
db_user = os.getenv("DB_USER")
db_password = os.getenv("DB_PASSWORD")
with engine.connect() as connection:
# Datenbank erstellen
connection.execute(
text(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
)
# Benutzer erstellen
connection.execute(
text(f"CREATE USER IF NOT EXISTS '{db_user}'@'%' IDENTIFIED BY '{db_password}'")
)
# Berechtigungen vergeben
connection.execute(
text(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'%'")
)
# Berechtigungen neu laden
connection.execute(text("FLUSH PRIVILEGES"))
print("✅ Datenbank und Benutzer erfolgreich erstellt.")

28
server/models.py Normal file
View File

@@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, Enum, TIMESTAMP, func
from sqlalchemy.orm import declarative_base
import enum
Base = declarative_base()
class UserRole(enum.Enum):
user = "user"
admin = "admin"
superadmin = "superadmin"
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(60), nullable=False)
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
created_at = Column(TIMESTAMP, server_default=func.current_timestamp())
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
class Client(Base):
__tablename__ = 'clients'
uuid = Column(String(36), primary_key=True, nullable=False)
hardware_hash = Column(String(64), nullable=False)
location = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True)
registration_time = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False)
last_alive = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), nullable=False)

View File

@@ -0,0 +1,148 @@
import os
import json
import base64
import glob
from datetime import datetime, timezone
# import paho.mqtt.client as mqtt
from paho.mqtt import client as mqtt_client
import pytz
from sqlalchemy import create_engine, func
from sqlalchemy.orm import sessionmaker
from models import Client, Base
from helpers.check_folder import ensure_folder_exists
# Konfiguration
MQTT_BROKER = os.getenv("MQTT_BROKER_HOST", "localhost")
MQTT_PORT = int(os.getenv("MQTT_BROKER_PORT", 1883))
MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE"))
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_HOST = os.getenv("DB_HOST")
DB_NAME = os.getenv("DB_NAME")
topics = [
("infoscreen/screenshot", 0),
("infoscreen/heartbeat", 0),
# ... weitere Topics hier
]
SAVE_DIR = "received_screenshots"
MAX_PER_CLIENT = 20
# Ordner für empfangene Screenshots anlegen
ensure_folder_exists(SAVE_DIR)
# Datenbank konfigurieren (MariaDB)
# Ersetze user, password, host und datenbankname entsprechend.
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
engine = create_engine(DB_URL, echo=False)
Session = sessionmaker(bind=engine)
# Falls Tabellen noch nicht existieren
Base.metadata.create_all(engine)
def prune_old_screenshots(client_id: str):
"""Löscht alte Screenshots, wenn mehr als MAX_PER_CLIENT vorhanden sind."""
pattern = os.path.join(SAVE_DIR, f"{client_id}_*.jpg")
files = sorted(glob.glob(pattern), key=os.path.getmtime)
while len(files) > MAX_PER_CLIENT:
oldest = files.pop(0)
try:
os.remove(oldest)
print(f"Altes Bild gelöscht: {oldest}")
except OSError as e:
print(f"Fehler beim Löschen von {oldest}: {e}")
def handle_screenshot(msg):
"""Verarbeitet eingehende Screenshot-Payloads."""
try:
payload = json.loads(msg.payload.decode("utf-8"))
client_id = payload.get("client_id", "unknown")
ts = datetime.fromtimestamp(
payload.get("timestamp", datetime.now().timestamp())
)
b64_str = payload["screenshot"]
img_data = base64.b64decode(b64_str)
# Dateiname mit Client-ID und Zeitstempel
filename = ts.strftime(f"{client_id}_%Y%m%d_%H%M%S.jpg")
filepath = os.path.join(SAVE_DIR, filename)
# Bild speichern
with open(filepath, "wb") as f:
f.write(img_data)
print(f"Bild gespeichert: {filepath}")
# Alte Screenshots beschneiden
prune_old_screenshots(client_id)
except Exception as e:
print("Fehler beim Verarbeiten der Screenshot-Nachricht:", e)
def handle_heartbeat(msg):
"""Verarbeitet Heartbeat und aktualisiert oder legt Clients an."""
session = Session()
try:
payload = json.loads(msg.payload.decode("utf-8"))
uuid = payload.get("client_id")
hardware_hash = payload.get("hardware_hash")
ip_address = payload.get("ip_address")
# Versuche, Client zu finden
client = session.query(Client).filter_by(uuid=uuid).first()
if client:
# Bekannter Client: last_alive und IP aktualisieren
client.ip_address = ip_address
client.last_alive = func.now()
session.commit()
print(f"Heartbeat aktualisiert für Client {uuid}")
else:
# Neuer Client: Location per input abfragen
location = input(f"Neuer Client {uuid} gefunden. Bitte Standort eingeben: ")
# ip_address = msg._sock.getpeername()[0]
new_client = Client(
uuid=uuid,
hardware_hash=hardware_hash,
location=location,
ip_address=ip_address
)
session.add(new_client)
session.commit()
print(f"Neuer Client {uuid} angelegt mit Standort {location}")
except Exception as e:
print("Fehler beim Verarbeiten der Heartbeat-Nachricht:", e)
session.rollback()
finally:
session.close()
# Mapping von Topics auf Handler-Funktionen
handlers = {
"infoscreen/screenshot": handle_screenshot,
"infoscreen/heartbeat": handle_heartbeat,
# ... weitere Zuordnungen hier
}
def on_connect(client, userdata, flags, rc, properties):
print("Verbunden mit Code:", rc)
client.subscribe(topics)
def on_message(client, userdata, msg):
topic = msg.topic
if topic in handlers:
handlers[topic](msg)
else:
print(f"Unbekanntes Topic '{topic}', keine Verarbeitung definiert.")
if __name__ == "__main__":
client = mqtt_client.Client(callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2)
client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=MQTT_KEEPALIVE)
client.loop_forever()

57
server/mqtt_receiver.py Normal file
View File

@@ -0,0 +1,57 @@
import os
import base64
import json
from datetime import datetime
import paho.mqtt.client as mqtt
# MQTT-Konfiguration
MQTT_BROKER = "mqtt_broker"
MQTT_PORT = 1883
MQTT_USER = "infoscreen_taa_user"
MQTT_PASSWORD = "infoscreen_taa_MQTT25!"
TOPIC_SCREENSHOTS = "infoscreen/screenshot"
SAVE_DIR = "received_screenshots"
topics = [
("infoscreen/screenshot", 0),
("infoscreen/heartbeat", 0),
# ... weitere Topics hier
]
# Ordner für empfangene Screenshots anlegen
os.makedirs(SAVE_DIR, exist_ok=True)
# Callback, wenn eine Nachricht eintrifft
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode('utf-8'))
b64_str = payload["screenshot"]
img_data = base64.b64decode(b64_str)
# Dateiname mit Zeitstempel
ts = datetime.fromtimestamp(payload.get("timestamp", datetime.now().timestamp()))
filename = ts.strftime("screenshot_%Y%m%d_%H%M%S.jpg")
filepath = os.path.join(SAVE_DIR, filename)
# Bild speichern
with open(filepath, "wb") as f:
f.write(img_data)
print(f"Bild gespeichert: {filepath}")
except Exception as e:
print("Fehler beim Verarbeiten der Nachricht:", e)
# Callback bei erfolgreicher Verbindung
def on_connect(client, userdata, flags, rc, properties):
if rc == 0:
print("Mit MQTT-Server verbunden.")
client.subscribe(TOPIC_SCREENSHOTS, qos=1)
else:
print(f"Verbindung fehlgeschlagen (Code {rc})")
if __name__ == "__main__":
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
client.loop_forever()

16
server/nano.7258.save Normal file
View File

@@ -0,0 +1,16 @@
# server/wsgi.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify(status="ok")
# Optional: Test-Route
@app.route("/")
def index():
return "Hello from InfoscreenAPI!"
# (Weitere Endpunkte, Blueprints, Datenbank-Initialisierung usw. kommen hierher)

View File

@@ -0,0 +1 @@
python-dotenv>=1.1.0

6
server/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
bcrypt>=4.3.0
paho-mqtt>=2.1.0
PyMySQL>=1.1.1
python-dotenv>=1.1.0
SQLAlchemy>=2.0.41
flask

26
server/test_sql.py Normal file
View File

@@ -0,0 +1,26 @@
import os
from dotenv import load_dotenv
import pymysql
load_dotenv()
try:
connection = pymysql.connect(
host='localhost',
port=3306,
user=os.getenv('DB_USER'),
password=os.getenv('DB_PASSWORD'),
database=os.getenv('DB_NAME')
)
print("✅ Verbindung erfolgreich!")
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
version = cursor.fetchone()
print(f"MariaDB Version: {version[0]}")
except Exception as e:
print(f"❌ Verbindungsfehler: {e}")
finally:
if 'connection' in locals():
connection.close()

16
server/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
# server/wsgi.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify(status="ok")
# Optional: Test-Route
@app.route("/")
def index():
return "Hello from InfoscreenAPI!"
# (Weitere Endpunkte, Blueprints, Datenbank-Initialisierung usw. kommen hierher)