Compare commits

..

16 Commits

Author SHA1 Message Date
Aiirondev_dev 5a2d703bc7 Debug of Vapid Keys 2026-07-29 13:35:15 +02:00
Aiirondev_dev 8e6dd17243 Fix of the notification subscription 2026-07-29 11:57:34 +02:00
Aiirondev_dev 2124ca65b2 Fix of the notification subscription 2026-07-29 11:49:54 +02:00
Aiirondev_dev bc86dc4a0d Fix of the notification subscription 2026-07-29 11:43:18 +02:00
Aiirondev_dev a49ed98ed7 Fix of the notification subscription 2026-07-29 11:35:04 +02:00
Aiirondev_dev 4d9bb62907 Fix of the notification subscription 2026-07-29 11:15:57 +02:00
Aiirondev_dev 8251cd9bfd Fix of a dubled function 2026-07-29 00:22:35 +02:00
Aiirondev_dev 3ed3148b8a Fix of the notifikationssystem 2026-07-29 00:10:12 +02:00
Aiirondev_dev 27b265eaf5 Addition of a missing Funktion 2026-07-29 00:03:14 +02:00
Aiirondev_dev 3571bb6f6d Fix of the Notifications Funktions and the notifications system in generell in regarts to the Vapid key handeling 2026-07-28 23:46:15 +02:00
Aiirondev_dev b037434e89 fix correct VAPID key loading and public key assignment when PEM files exist 2026-07-28 23:21:35 +02:00
Aiirondev_dev ad14499df0 fix of the direction for _match_mail 2026-07-28 22:43:12 +02:00
Aiirondev_dev 6f50fb2263 Changes from old deployment system to new dynamic one 2026-07-28 22:09:40 +02:00
Aiirondev_dev a1c5a78b20 Changes for the System to accept the changes 2026-07-28 21:53:48 +02:00
Aiirondev_dev 38320f488a patch for the Secret Getting that is not functioning 2026-07-28 21:47:37 +02:00
Aiirondev_dev 2aee36cc92 Updates to the Update Group logic to have a cleaner output 2026-07-28 21:23:21 +02:00
12 changed files with 251 additions and 455 deletions
+10 -55
View File
@@ -25,7 +25,6 @@ env:
jobs: jobs:
release-docker: release-docker:
# Hinweis: Falls dein lokaler Gitea-Runner ein anderes Label hat (z.B. 'linux' oder 'self-hosted'), passe dies hier an.
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -35,7 +34,6 @@ jobs:
- name: Set metadata - name: Set metadata
id: meta id: meta
env: env:
# Gitea stellt das GITHUB_TOKEN für Kompatibilität mit GitHub Actions automatisch zur Verfügung
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ gitea.repository }} REPO: ${{ gitea.repository }}
EVENT_NAME: ${{ gitea.event_name }} EVENT_NAME: ${{ gitea.event_name }}
@@ -46,7 +44,6 @@ jobs:
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
TAG="$REF_NAME" TAG="$REF_NAME"
else else
# Gitea API Endpunkt nutzen, um das aktuellste Release abzufragen
latest_tag="v0.8.31" latest_tag="v0.8.31"
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases/latest" 2>/dev/null); then if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases/latest" 2>/dev/null); then
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
@@ -63,7 +60,6 @@ jobs:
major=0; minor=8; patch=31 major=0; minor=8; patch=31
fi fi
# Bump strategy: major / minor / patch
if [ "${BUMP_TYPE:-}" = "major" ]; then if [ "${BUMP_TYPE:-}" = "major" ]; then
major=$((major + 1)); minor=0; patch=0 major=$((major + 1)); minor=0; patch=0
elif [ "${BUMP_TYPE:-}" = "minor" ]; then elif [ "${BUMP_TYPE:-}" = "minor" ]; then
@@ -72,11 +68,9 @@ jobs:
patch=$((patch + 1)) patch=$((patch + 1))
fi fi
# Zusammenbau des Tags für den manuellen Run
TAG="v${major}.${minor}.${patch}" TAG="v${major}.${minor}.${patch}"
fi fi
# Validierung des erzeugten oder übergebenen Tags
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)" echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
exit 1 exit 1
@@ -95,13 +89,11 @@ jobs:
LATEST_MAJOR="0" LATEST_MAJOR="0"
fi fi
# If not explicitly bumping major, disallow changing major version
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)" echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
exit 1 exit 1
fi fi
# Ensure tag uniqueness: if tag exists append numeric suffix
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1 i=1
base="$TAG" base="$TAG"
@@ -111,24 +103,18 @@ jobs:
TAG="${base}.${i}" TAG="${base}.${i}"
fi fi
# Docker Images verlangen Kleinbuchstaben. Repository-Namen daher umwandeln.
LOWER_REPO=$(echo "$REPO" | tr '[:upper:]' '[:lower:]') LOWER_REPO=$(echo "$REPO" | tr '[:upper:]' '[:lower:]')
IMAGE="git.invario-software.eu/${LOWER_REPO}:${TAG}" IMAGE="git.invario-software.eu/${LOWER_REPO}:${TAG}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT" echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT" echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
- name: Ensure Docker CLI is available and up to date - name: Ensure Docker CLI is available and up to date
run: | run: |
install_docker=true install_docker=true
# Prüfen, ob Docker existiert und ob die Version ausreicht
if command -v docker >/dev/null 2>&1; then if command -v docker >/dev/null 2>&1; then
# Extrahiere die Major-Version
DOCKER_MAJOR=$(docker --version | grep -oE '[0-9]+' | head -n1) DOCKER_MAJOR=$(docker --version | grep -oE '[0-9]+' | head -n1)
# API 1.44 erfordert mindestens Docker v25
if [ -n "$DOCKER_MAJOR" ] && [ "$DOCKER_MAJOR" -ge 25 ]; then if [ -n "$DOCKER_MAJOR" ] && [ "$DOCKER_MAJOR" -ge 25 ]; then
install_docker=false install_docker=false
fi fi
@@ -136,34 +122,19 @@ jobs:
if [ "$install_docker" = true ]; then if [ "$install_docker" = true ]; then
echo "Veraltete oder fehlende Docker-Installation erkannt. Lade statische Docker CLI herunter..." echo "Veraltete oder fehlende Docker-Installation erkannt. Lade statische Docker CLI herunter..."
DOCKER_VERSION="26.1.4" DOCKER_VERSION="26.1.4"
# Download der statischen Binaries via curl oder wget (umgeht apt-get komplett)
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
curl -fsSLO "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz" curl -fsSLO "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz"
else else
wget -q "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz" wget -q "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz"
fi fi
tar -xzf docker-${DOCKER_VERSION}.tgz tar -xzf docker-${DOCKER_VERSION}.tgz
# Installation in lokalen Benutzer-Pfad, um sudo/root-Rechte-Probleme zu vermeiden
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
cp docker/docker "$HOME/.local/bin/" cp docker/docker "$HOME/.local/bin/"
# Pfad für nachfolgende GitHub Actions Schritte verfügbar machen
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
# Pfad für diesen spezifischen Shell-Run exportieren
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
rm -rf docker docker-${DOCKER_VERSION}.tgz rm -rf docker docker-${DOCKER_VERSION}.tgz
echo "Docker CLI wurde erfolgreich aktualisiert."
else
echo "Docker CLI ist bereits auf einem aktuellen Stand."
fi fi
docker --version docker --version
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -186,18 +157,6 @@ jobs:
${{ steps.meta.outputs.image }} ${{ steps.meta.outputs.image }}
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
- name: Build local image tar for offline deploy
run: |
set -euo pipefail
IMG="${{ steps.meta.outputs.image }}"
TAG="${{ steps.meta.outputs.tag }}"
echo "Pulling freshly pushed image from registry: $IMG"
docker pull "$IMG"
echo "Saving image to offline tar archive..."
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
- name: Create release-only docker bundle - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
@@ -218,14 +177,14 @@ jobs:
INVENTAR_MONGODB_DB: Inventarsystem INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_SECRET_KEY: \${INVENTAR_SECRET_KEY} INVENTAR_SECRET_KEY: ${{secrets.INVENTAR_SECRET_KEY}}
INVENTAR_DATA_ENCRYPTION_KEY: \${INVENTAR_DATA_ENCRYPTION_KEY} INVENTAR_DATA_ENCRYPTION_KEY: ${{secrets.INVENTAR_DATA_ENCRYPTION_KEY}}
INVENTAR_MONGODB_PASSWORD: \${INVENTAR_MONGODB_PASSWORD} INVENTAR_MONGODB_PASSWORD: ${{secrets.INVENTAR_MONGODB_PASSWORD}}
EMAIL_ENABLED: \${EMAIL_ENABLED} EMAIL_ENABLED: ${{secrets.EMAIL_ENABLED}}
EMAIL_SMTP_HOST: \${EMAIL_SMTP_HOST} EMAIL_SMTP_HOST: ${{secrets.EMAIL_SMTP_HOST}}
EMAIL_SMTP_PORT: \${EMAIL_SMTP_PORT} EMAIL_SMTP_PORT: ${{secrets.EMAIL_SMTP_PORT}}
EMAIL_USERNAME: \${EMAIL_USERNAME} EMAIL_USERNAME: ${{secrets.EMAIL_USERNAME}}
EMAIL_PASSWORD: \${EMAIL_PASSWORD} EMAIL_PASSWORD: ${{secrets.EMAIL_PASSWORD}}
expose: expose:
- "8000" - "8000"
volumes: volumes:
@@ -275,21 +234,18 @@ jobs:
redis_data: redis_data:
EOF EOF
# Copy runtime scripts and config if present
for f in start.sh stop.sh restart.sh backup.sh restore.sh config.json update.sh; do for f in start.sh stop.sh restart.sh backup.sh restore.sh config.json update.sh; do
if [ -f "$f" ]; then if [ -f "$f" ]; then
cp "$f" "release-bundle/$(basename "$f")" cp "$f" "release-bundle/$(basename "$f")"
fi fi
done done
# Multitenant scripts & docs (optional)
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
if [ -f "$f" ]; then if [ -f "$f" ]; then
cp "$f" "release-bundle/$(basename "$f")" cp "$f" "release-bundle/$(basename "$f")"
fi fi
done done
# Make any shipped scripts executable
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle . tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
@@ -298,5 +254,4 @@ jobs:
with: with:
tag_name: ${{ steps.meta.outputs.tag }} tag_name: ${{ steps.meta.outputs.tag }}
files: | files: |
inventarsystem-docker-bundle.tar.gz inventarsystem-docker-bundle.tar.gz
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
+86 -94
View File
@@ -1824,9 +1824,9 @@ def _excel_list(value):
seen = set() seen = set()
unique = [] unique = []
for entry in cleaned: for entry in cleaned:
if entry not in seen: if str(entry) not in seen:
unique.append(entry) unique.append(entry)
seen.add(entry) seen.add(str(entry))
return unique return unique
@@ -6737,6 +6737,8 @@ def update_group():
{'$set': shared_update} {'$set': shared_update}
) )
app.logger.debug(f"Individual Codes: {individual_items}")
# B. Apply Unique Codes to specific items # B. Apply Unique Codes to specific items
# We iterate through the provided list to update the specific code for each ID # We iterate through the provided list to update the specific code for each ID
for item in individual_items: for item in individual_items:
@@ -6750,6 +6752,8 @@ def update_group():
) )
client.close() client.close()
app.logger.debug("Success When Updating the Item")
flash("Objekte wurden erfolgreich Bearbeitet", "success")
return jsonify({'success': True, 'message': 'Gruppe und individuelle Codes aktualisiert'}) return jsonify({'success': True, 'message': 'Gruppe und individuelle Codes aktualisiert'})
except Exception as e: except Exception as e:
@@ -10369,21 +10373,39 @@ def my_borrowed_items():
) )
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""
Returns the VAPID public key for web push subscriptions
"""
from Web.push_notifications import _get_vapid_public
if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try:
if not _get_vapid_public():
return jsonify({'success': False, 'error': 'VAPID public key not configured'}), 500
return jsonify({
'success': True,
'publicKey': _get_vapid_public()
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False}), 500
@app.route('/notifications') @app.route('/notifications')
def notifications_view(): def notifications_view():
"""Notification center for users and admins.""" """Notification center for users and admins."""
from Web.push_notifications import _get_vapid_public
if 'username' not in session: if 'username' not in session:
flash('Bitte melden Sie sich an, um Benachrichtigungen zu sehen.', 'error') flash('Bitte melden Sie sich an, um Benachrichtigungen zu sehen.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
username = session['username'] username = session['username']
is_admin_user = False
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['actions'].get('can_manage_settings', False): is_admin_user = current_permissions['actions'].get('can_manage_settings', False)
is_admin_user = True
else:
is_admin_user = False
client = None client = None
try: try:
@@ -10395,15 +10417,17 @@ def notifications_view():
admin_notifications = [] admin_notifications = []
for notif in notifications: for notif in notifications:
is_read = username in (notif.get('ReadBy') or []) is_read = username in (notif.get('ReadBy') or [])
created_at_val = notif.get('CreatedAt')
row = { row = {
'id': str(notif.get('_id')), 'id': str(notif.get('_id')),
'title': notif.get('Title', 'Benachrichtigung'), 'title': notif.get('Title', 'Benachrichtigung'),
'message': notif.get('Message', ''), 'message': notif.get('Message', ''),
'severity': notif.get('Severity', 'info'), 'severity': notif.get('Severity', 'info'),
'type': notif.get('Type', ''), 'type': notif.get('Type', ''),
'created_at': notif.get('CreatedAt'), 'created_at': created_at_val if created_at_val else None,
'is_read': is_read, 'is_read': is_read,
'reference': notif.get('Reference', {}) or {}, 'reference': notif.get('Reference') or {},
} }
if notif.get('Audience') == 'admin': if notif.get('Audience') == 'admin':
admin_notifications.append(row) admin_notifications.append(row)
@@ -10417,6 +10441,7 @@ def notifications_view():
is_admin_user=is_admin_user, is_admin_user=is_admin_user,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
vapid_public_key=_get_vapid_public()
) )
except Exception as exc: except Exception as exc:
app.logger.error(f"Error loading notifications: {exc}") app.logger.error(f"Error loading notifications: {exc}")
@@ -10456,45 +10481,49 @@ def mark_notification_read(notification_id):
return redirect(url_for('notifications_view')) return redirect(url_for('notifications_view'))
@app.route('/notifications/mark_all_read', methods=['POST']) @app.route('/notifications/mark-all-read', methods=['POST'])
def mark_all_notifications_read(): def mark_all_notifications_read():
"""Mark all visible notifications as read for the current user."""
if 'username' not in session: if 'username' not in session:
flash('Bitte melden Sie sich an.', 'error')
return redirect(url_for('login')) return redirect(url_for('login'))
username = session['username'] username = session['username']
is_admin_user = False
current_permissions = us.get_effective_permissions(session['username']) current_permissions = us.get_effective_permissions(username)
is_admin_user = current_permissions['actions'].get('can_manage_settings', False)
if not current_permissions['actions'].get('can_manage_settings', False):
is_admin_user = True
else:
is_admin_user = False
query = {
'$or': [
{'Audience': 'user', 'TargetUser': username},
]
}
if is_admin_user:
query['$or'].append({'Audience': 'admin'})
client = None client = None
try: try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB] db = client[MONGODB_DB]
result = db['notifications'].update_many(
# Filter-Logik: Welche Benachrichtigungen sollen als gelesen markiert werden?
# Wenn Sie 'Audience' nutzen (wie in Ihrer notifications_view Route):
query = {}
if is_admin_user:
# Ein Admin sieht sowohl an ihn gerichtete als auch allgemeine Admin-Meldungen
query['$or'] = [
{'TargetUser': username},
{'Audience': 'admin'}
]
else:
# Normale User sehen nur Meldungen, die an sie oder alle User gerichtet sind
query['$or'] = [
{'TargetUser': username},
{'Audience': 'user'}
]
# WICHTIG: Fügt den Benutzernamen per $addToSet in das ReadBy-Array ein.
# $addToSet verhindert, dass der Name doppelt eingetragen wird, falls er schon drinsteht.
db['notifications'].update_many(
query, query,
{ {'$addToSet': {'ReadBy': username}}
'$addToSet': {'ReadBy': username},
'$set': {'UpdatedAt': datetime.datetime.now()}
}
) )
if result.modified_count > 0:
_bump_notification_version(f'user:{username}') flash('Alle Benachrichtigungen wurden als gelesen markiert.', 'success')
except Exception as exc: except Exception as exc:
app.logger.warning(f"Could not mark all notifications as read for {encrypt_text(username)}: {exc}") app.logger.error(f"Error marking all notifications as read: {exc}")
flash('Fehler beim Aktualisieren der Benachrichtigungen.', 'error')
finally: finally:
if client: if client:
client.close() client.close()
@@ -12105,44 +12134,33 @@ def get_optimal_image_quality(img, target_size_kb=80):
def health_check(): def health_check():
return 'OK', 200 return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST']) @app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push(): def subscribe_to_push():
"""
Subscribe a user to push notifications
Expects JSON payload:
{
'subscription': {
'endpoint': '...',
'keys': {'p256dh': '...', 'auth': '...'}
}
}
"""
if 'username' not in session: if 'username' not in session:
return jsonify({'success': False, 'error': 'Not authenticated'}), 401 return jsonify({'success': False, 'error': 'Unauthorized'}), 401
data = request.get_json(silent=True) or {}
subscription_obj = data.get('subscription') if 'subscription' in data else data
if not subscription_obj or not isinstance(subscription_obj, dict):
app.logger.error("Invalid subscription payload received")
return jsonify({'success': False, 'error': 'Invalid payload'}), 400
username = session['username']
try: try:
data = request.get_json() or {} success = pn.save_push_subscription(username, subscription_obj)
subscription = data.get('subscription')
if not subscription or not subscription.get('endpoint'):
return jsonify({'success': False, 'error': 'Invalid subscription'}), 400
username = session['username']
success = pn.save_push_subscription(username, subscription)
if success: if success:
app.logger.info(f'Push subscription saved for {encrypt_text(username)}') return jsonify({'success': True})
return jsonify({
'success': True,
'message': 'Successfully subscribed to push notifications'
})
else: else:
return jsonify({'success': False, 'error': 'Failed to save subscription'}), 500 return jsonify({'success': False, 'error': 'Failed to save in DB'}), 400
except Exception as e: except Exception as e:
app.logger.error(f'Error subscribing to push: {e}') app.logger.error(f"Error in subscribe_to_push route: {e}")
return jsonify({'success': False}), 500 return jsonify({'success': False, 'error': 'Internal server error'}), 500
@app.route('/api/push/unsubscribe', methods=['POST']) @app.route('/api/push/unsubscribe', methods=['POST'])
@@ -12159,7 +12177,7 @@ def unsubscribe_from_push():
return jsonify({'success': False, 'error': 'Not authenticated'}), 401 return jsonify({'success': False, 'error': 'Not authenticated'}), 401
try: try:
data = request.get_json() or {} data = request.get_json(silent=True) or {}
endpoint = data.get('endpoint') endpoint = data.get('endpoint')
if not endpoint: if not endpoint:
@@ -12215,32 +12233,6 @@ def get_push_subscriptions():
app.logger.error(f'Error getting push subscriptions: {e}') app.logger.error(f'Error getting push subscriptions: {e}')
return jsonify({'success': False}), 500 return jsonify({'success': False}), 500
@app.route('/api/push/vapid-key', methods=['GET'])
def get_vapid_key():
"""
Get the VAPID public key for push notifications
Used by the service worker to communicate with push service
"""
try:
vapid_key = pn.VAPID_PUBLIC_KEY
if not vapid_key:
app.logger.warning('VAPID_PUBLIC_KEY not configured')
return jsonify({
'success': False,
'error': 'Push notifications not configured on server'
}), 501
return jsonify({
'success': True,
'vapid_key': vapid_key
})
except Exception as e:
app.logger.error(f'Error getting VAPID key: {e}')
return jsonify({'success': False}), 500
@app.route('/api/push/test', methods=['POST']) @app.route('/api/push/test', methods=['POST'])
def test_push_notification(): def test_push_notification():
""" """
@@ -12253,7 +12245,7 @@ def test_push_notification():
return jsonify({'success': False, 'error': 'Admin access required'}), 403 return jsonify({'success': False, 'error': 'Admin access required'}), 403
try: try:
data = request.get_json() or {} data = request.get_json(silent=True) or {}
target_user = data.get('target_user', session['username']) target_user = data.get('target_user', session['username'])
sent = pn.send_push_notification( sent = pn.send_push_notification(
+1 -1
View File
@@ -300,7 +300,7 @@ def _match_student_cards(path):
def _match_mail(path): def _match_mail(path):
if not path: return False if not path: return False
return path.startswith(('/')) return path.startswith(('/configure'))
# Register core modules into the pipeline # Register core modules into the pipeline
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory) MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
+78 -56
View File
@@ -20,40 +20,53 @@ logger = logging.getLogger(__name__)
# VAPID keys for push notifications # VAPID keys for push notifications
VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '') VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '')
VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '') VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '')
VAPID_SUBJECT = os.getenv('VAPID_SUBJECT', f'mailto:admin@{os.getenv("SERVER_NAME", "localhost")}') VAPID_SUBJECT = os.getenv('VAPID_SUBJECT', f'mailto:support@invario-software.de')
# VAPID keys file paths
VAPID_PRIVATE_PEM = os.path.join(os.path.dirname(__file__), 'vapid_private.pem') VAPID_PRIVATE_PEM = os.path.join(os.path.dirname(__file__), 'vapid_private.pem')
VAPID_PUBLIC_PEM = os.path.join(os.path.dirname(__file__), 'vapid_public.pem') VAPID_PUBLIC_PEM = os.path.join(os.path.dirname(__file__), 'vapid_public.pem')
# Auto-generate VAPID keys if none are provided # Load or auto-generate VAPID keys
if not VAPID_PUBLIC_KEY or not VAPID_PRIVATE_KEY: try:
try: from py_vapid import Vapid, b64urlencode
from py_vapid import Vapid, b64urlencode from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import serialization
if not os.path.exists(VAPID_PRIVATE_PEM) or not os.path.exists(VAPID_PUBLIC_PEM):
vapid = Vapid() vapid = Vapid()
if not os.path.exists(VAPID_PRIVATE_PEM): vapid.generate_keys()
vapid.generate_keys() vapid.save_key(VAPID_PRIVATE_PEM)
vapid.save_key(VAPID_PRIVATE_PEM) vapid.save_public_key(VAPID_PUBLIC_PEM)
vapid.save_public_key(VAPID_PUBLIC_PEM) logger.info("Auto-generated new VAPID keys")
logger.info("Auto-generated new VAPID keys") else:
else: vapid = Vapid.from_file(VAPID_PRIVATE_PEM)
vapid = Vapid.from_file(VAPID_PRIVATE_PEM)
raw_pub = vapid.public_key.public_bytes(
raw_pub = vapid.public_key.public_bytes( serialization.Encoding.X962,
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
serialization.PublicFormat.UncompressedPoint )
)
encoded_pub = b64urlencode(raw_pub)
VAPID_PUBLIC_KEY = b64urlencode(raw_pub).decode('utf-8') if isinstance(encoded_pub, bytes):
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM VAPID_PUBLIC_KEY = encoded_pub.decode('utf-8')
except Exception as e: else:
logger.error(f'Could not load or generate VAPID keys: {e}') VAPID_PUBLIC_KEY = encoded_pub
VAPID_PRIVATE_KEY = VAPID_PRIVATE_PEM
except Exception as e:
logger.error(f'Could not load or generate VAPID keys: {e}')
VAPID_PUBLIC_KEY = os.getenv('VAPID_PUBLIC_KEY', '')
VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY', '')
VAPID_SUBJECT = os.getenv('VAPID_SUBJECT', 'mailto:support@invario-software.de')
# Push service endpoint (typically Firebase or Web Push Service) # Push service endpoint (typically Firebase or Web Push Service)
FCM_API_KEY = os.getenv('FCM_API_KEY', '') # Firebase API key FCM_API_KEY = os.getenv('FCM_API_KEY', '')
def _get_vapid_public():
return VAPID_PUBLIC_KEY
def _get_username_hash(username): def _get_username_hash(username):
"""Generates a deterministic hash for database lookups.""" """Generates a deterministic hash for database lookups."""
if not username: if not username:
@@ -109,29 +122,33 @@ def get_user_subscriptions(username):
def save_push_subscription(username, subscription_obj): def save_push_subscription(username, subscription_obj):
""" import traceback # Hilft uns, Fehler genau zu sehen
Save a new push subscription for a user with field-level encryption.
"""
try: try:
print("--- DEBUG PUSH PAYLOAD ---")
print(subscription_obj)
endpoint = subscription_obj.get('endpoint') endpoint = subscription_obj.get('endpoint')
if not endpoint: keys = subscription_obj.get('keys', {})
logger.warning('Invalid subscription object: missing endpoint')
if not endpoint or not keys.get('p256dh') or not keys.get('auth'):
print(
f"DEBUG FEHLER: Keys fehlen! Endpoint: {bool(endpoint)}, p256dh: {bool(keys.get('p256dh'))}, auth: {bool(keys.get('auth'))}")
return False return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
# Create unique hash of subscription using plaintext data to avoid duplicates # Create unique hash of subscription using plaintext data to avoid duplicates
sub_hash = hashlib.shake_256( sub_hash = hashlib.shake_256(
f"{username}:{endpoint}".encode('utf-8') f"{username}:{endpoint}".encode('utf-8')
).hexdigest() ).hexdigest()
# Check if subscription already exists by Hash # Check if subscription already exists by Hash
existing = subs_col.find_one({ existing = subs_col.find_one({
'SubscriptionHash': sub_hash 'SubscriptionHash': sub_hash
}) })
if existing: if existing:
subs_col.update_one( subs_col.update_one(
{'_id': existing['_id']}, {'_id': existing['_id']},
@@ -143,10 +160,10 @@ def save_push_subscription(username, subscription_obj):
logger.info('Updated existing push subscription') logger.info('Updated existing push subscription')
client.close() client.close()
return True return True
# Format keys as JSON string for your encrypt_text module # Format keys as JSON string for your encrypt_text module
keys_str = json.dumps(subscription_obj.get('keys', {})) keys_str = json.dumps(subscription_obj.get('keys', {}))
# Save new subscription, encrypting sensitive fields # Save new subscription, encrypting sensitive fields
subscription_doc = { subscription_doc = {
'UsernameHash': _get_username_hash(username), 'UsernameHash': _get_username_hash(username),
@@ -159,39 +176,37 @@ def save_push_subscription(username, subscription_obj):
'LastUsed': datetime.datetime.now(), 'LastUsed': datetime.datetime.now(),
'UserAgent': subscription_obj.get('userAgent', ''), 'UserAgent': subscription_obj.get('userAgent', ''),
} }
subs_col.insert_one(subscription_doc) subs_col.insert_one(subscription_doc)
logger.info('Saved new encrypted push subscription') logger.info('Saved new encrypted push subscription')
client.close() client.close()
return True return True
except Exception as e: except Exception as e:
logger.error(f'Error saving push subscription: {e}') print(f"DEBUG ABSTURZ in save_push_subscription: {e}")
traceback.print_exc()
return False return False
def remove_push_subscription(username, endpoint): def remove_push_subscription(username, endpoint):
"""
Remove a push subscription by making it inactive.
"""
try: try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
subs_col = get_push_subscriptions_collection(db) subs_col = get_push_subscriptions_collection(db)
# Recreate the deterministic hash to find the specific subscription
sub_hash = hashlib.shake_256( sub_hash = hashlib.shake_256(
f"{username}:{endpoint}".encode('utf-8') f"{username}:{endpoint}".encode('utf-8')
).hexdigest() ).hexdigest()
result = subs_col.update_one( result = subs_col.update_one(
{'SubscriptionHash': sub_hash}, {'SubscriptionHash': sub_hash},
{'$set': {'IsActive': False}} {'$set': {'IsActive': False}}
) )
client.close() client.close()
return result.modified_count > 0 return result.matched_count > 0
except Exception as e: except Exception as e:
logger.error(f'Error removing push subscription: {e}') logger.error(f'Error removing push subscription: {e}')
return False return False
@@ -299,8 +314,8 @@ def _send_fcm_notification(subscription, payload):
def _send_web_push_notification(subscription, payload): def _send_web_push_notification(subscription, payload):
try: try:
from pywebpush import webpush from pywebpush import webpush, WebPushException
webpush( webpush(
subscription_info={ subscription_info={
'endpoint': subscription['Endpoint'], 'endpoint': subscription['Endpoint'],
@@ -310,18 +325,25 @@ def _send_web_push_notification(subscription, payload):
vapid_private_key=VAPID_PRIVATE_KEY, vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims={'sub': VAPID_SUBJECT}, vapid_claims={'sub': VAPID_SUBJECT},
timeout=10, timeout=10,
ttl=3600 ttl=3600
) )
return True return True
except ImportError: except ImportError:
logger.warning('pywebpush not installed. pip install pywebpush') logger.warning('pywebpush not installed. pip install pywebpush')
return False return False
except Exception as e: # HÄRTUNG: Spezifische WebPush-Fehler abfangen
logger.error(f'Web push error: {e}') except WebPushException as ex:
return False # HTTP 404 (Not Found) oder 410 (Gone) bedeuten: Abo existiert nicht mehr
if ex.response is not None and ex.response.status_code in [404, 410]:
logger.info(f"Subscription expired or revoked (Code {ex.response.status_code}). Marking inactive.")
return False # False signalisiert der übergeordneten Funktion, das Abo zu deaktivieren
logger.error(f'Web push failed with code {ex.response.status_code if ex.response else "Unknown"}: {ex}')
return False
except Exception as e:
logger.error(f'Unexpected Web push error: {e}')
return False
def _mark_subscription_inactive(subscription_id): def _mark_subscription_inactive(subscription_id):
try: try:
+2 -1
View File
@@ -16,4 +16,5 @@ openpyxl
cryptography>=42.0.0 cryptography>=42.0.0
pywebpush pywebpush
py-vapid>=1.9.0 py-vapid>=1.9.0
beautifulsoup4 beautifulsoup4
pywebpush
+26 -17
View File
@@ -25,7 +25,7 @@ class PushNotificationManager {
* Initialize push notification system * Initialize push notification system
* Must be called after page load * Must be called after page load
*/ */
async init() { async init(providedKey = null) {
if (!this.isSupported) { if (!this.isSupported) {
console.log('Push notifications not supported in this browser'); console.log('Push notifications not supported in this browser');
return false; return false;
@@ -49,11 +49,18 @@ class PushNotificationManager {
} }
} }
// Fetch VAPID public key from server // Wenn der Key direkt aus dem Template übergeben wurde, nutze diesen (spart einen Request)
if (providedKey) {
this.vapidKey = providedKey;
return true;
}
// Ansonsten: Fetch VAPID public key from server
const keyResponse = await fetch('/api/push/vapid-key'); const keyResponse = await fetch('/api/push/vapid-key');
if (keyResponse.ok) { if (keyResponse.ok) {
const keyData = await keyResponse.json(); const keyData = await keyResponse.json();
this.vapidKey = keyData.vapid_key; // WICHTIG: Muss exakt mit dem Python-JSON-Key übereinstimmen!
this.vapidKey = keyData.publicKey;
} else { } else {
console.warn('Failed to fetch VAPID key'); console.warn('Failed to fetch VAPID key');
return false; return false;
@@ -154,20 +161,22 @@ class PushNotificationManager {
try { try {
const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); const subscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
if (!subscription) { if (!subscription) {
console.warn('No active push subscription');
return false;
}
// Remove subscription on server
const success = await this.removeSubscriptionFromServer(subscription);
// Unsubscribe from push service
if (success) {
await subscription.unsubscribe();
return true; return true;
} }
return false; // Best-Effort: Server benachrichtigen (Eigener try/catch Block!)
try {
await this.removeSubscriptionFromServer(subscription);
} catch (serverError) {
// Fehler vom Server ignorieren wir absichtlich.
// Das Skript läuft weiter, statt hier abzubrechen!
console.warn('Server-Abmeldung fehlgeschlagen, lösche lokal trotzdem:', serverError);
}
// WICHTIG: Das hier wird jetzt garantiert ausgeführt
await subscription.unsubscribe();
return true;
} catch (error) { } catch (error) {
console.error('Failed to unsubscribe from push notifications:', error); console.error('Failed to unsubscribe from push notifications:', error);
return false; return false;
@@ -324,7 +333,7 @@ const pushNotificationManager = new PushNotificationManager();
/** /**
* Show notification subscription UI (typically in settings) * Show notification subscription UI (typically in settings)
*/ */
function showPushNotificationSettings() { function showPushNotificationSettings(vapidPublicKey) {
const container = document.getElementById('push-notification-settings'); const container = document.getElementById('push-notification-settings');
if (!container) return; if (!container) return;
@@ -350,9 +359,9 @@ function showPushNotificationSettings() {
container.innerHTML = html; container.innerHTML = html;
// Set up button handler // Set up button handler and pass the VAPID public key to init()
const toggleBtn = document.getElementById('toggle-push-btn'); const toggleBtn = document.getElementById('toggle-push-btn');
pushNotificationManager.init().then(() => { pushNotificationManager.init(vapidPublicKey).then(() => {
updatePushStatus(); updatePushStatus();
}); });
+5 -4
View File
@@ -1371,7 +1371,8 @@
await loadLibraryItems(); // Daten neu laden await loadLibraryItems(); // Daten neu laden
// renderTable(); // Ggf. Tabelle neu rendern // renderTable(); // Ggf. Tabelle neu rendern
} else { } else {
alert('Fehler: ' + result.message); await loadLibraryItems();
closeEditLibraryModal();
} }
} catch (error) { } catch (error) {
console.error('Update failed:', error); console.error('Update failed:', error);
@@ -1451,14 +1452,14 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong> <strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
</div> </div>
<p style="margin: 5px 0; font-size: 12px; color: #555;"> <p style="margin: 5px 0; font-size: 12px; color: #555;">
Alle Codes in dieser Gruppe: Alle Codes in dieser Gruppe:
</p> </p>
<!-- Hier wird die Liste als Komma-Text eingefügt --> <!-- Hier wird die Liste als Komma-Text eingefügt -->
<div id="editLibraryAllCodes" style="font-family: monospace; font-size: 14px; font-weight: bold; color: #333; margin-top: 5px;"></div> <div id="editLibraryAllCodes" style="font-family: monospace; font-size: 14px; font-weight: bold; color: #333; margin-top: 5px;"></div>
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;"> <div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen. <strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
</div> </div>
+1 -1
View File
@@ -89,7 +89,7 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof showPushNotificationSettings === 'function') { if (typeof showPushNotificationSettings === 'function') {
showPushNotificationSettings(); showPushNotificationSettings('{{ vapid_public_key }}');
} }
}); });
</script> </script>
+2 -30
View File
@@ -82,7 +82,6 @@ while i < len(lines):
stripped = line.lstrip(" ") stripped = line.lstrip(" ")
indent = leading_spaces(line) indent = leading_spaces(line)
# Check if we're starting the app service
if not in_app_service and re.match(r"^\s*app:\s*$", line): if not in_app_service and re.match(r"^\s*app:\s*$", line):
in_app_service = True in_app_service = True
app_indent = indent app_indent = indent
@@ -92,9 +91,7 @@ while i < len(lines):
i += 1 i += 1
continue continue
# Check if we've exited the app service (found another top-level service)
if in_app_service and indent <= app_indent and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped): if in_app_service and indent <= app_indent and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
# We've left the app service - insert image if we haven't already
if build_found and app_service_indent is not None: if build_found and app_service_indent is not None:
out.append(f"{' ' * app_service_indent}image: {target_image}\n") out.append(f"{' ' * app_service_indent}image: {target_image}\n")
in_app_service = False in_app_service = False
@@ -102,24 +99,20 @@ while i < len(lines):
i += 1 i += 1
continue continue
# Process lines within the app service
if in_app_service: if in_app_service:
if app_service_indent is None and indent > app_indent: if app_service_indent is None and indent > app_indent:
app_service_indent = indent app_service_indent = indent
# Check for image key (already has an image, don't add)
if re.match(rf"^\s+image:\s*", line): if re.match(rf"^\s+image:\s*", line):
in_app_service = False in_app_service = False
out.append(line) out.append(line)
i += 1 i += 1
continue continue
# Check for build block
if re.match(rf"^\s+build:\s*$", line): if re.match(rf"^\s+build:\s*$", line):
build_found = True build_found = True
out.append(line) out.append(line)
i += 1 i += 1
# Skip all lines that are part of the build block (indented more than app_service_indent)
while i < len(lines): while i < len(lines):
next_line = lines[i] next_line = lines[i]
next_indent = leading_spaces(next_line) next_indent = leading_spaces(next_line)
@@ -130,12 +123,10 @@ while i < len(lines):
break break
continue continue
# Insert image after build block before first property
if build_found and app_service_indent is not None and indent == app_service_indent: if build_found and app_service_indent is not None and indent == app_service_indent:
# Check if this is a property line (not build)
if not re.match(rf"^\s+build:", line): if not re.match(rf"^\s+build:", line):
out.append(f"{' ' * app_service_indent}image: {target_image}\n") out.append(f"{' ' * app_service_indent}image: {target_image}\n")
build_found = False # Mark that we've inserted the image build_found = False
out.append(line) out.append(line)
i += 1 i += 1
@@ -144,7 +135,6 @@ while i < len(lines):
out.append(line) out.append(line)
i += 1 i += 1
# If we ended while still in the app service, append image at the end
if in_app_service and build_found and app_service_indent is not None: if in_app_service and build_found and app_service_indent is not None:
out.append(f"{' ' * app_service_indent}image: {target_image}\n") out.append(f"{' ' * app_service_indent}image: {target_image}\n")
@@ -377,18 +367,12 @@ with open(meta_file, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
tag = data.get('tag_name', '').strip() tag = data.get('tag_name', '').strip()
url = '' url = ''
image_url = ''
for asset in data.get('assets', []): for asset in data.get('assets', []):
if asset.get('name') == asset_name: if asset.get('name') == asset_name:
url = asset.get('browser_download_url', '').strip() url = asset.get('browser_download_url', '').strip()
break break
for asset in data.get('assets', []):
if asset.get('name') == f'inventarsystem-image-{tag}.tar.gz':
image_url = asset.get('browser_download_url', '').strip()
break
print(tag) print(tag)
print(url) print(url)
print(image_url)
PY PY
} }
@@ -401,7 +385,7 @@ main() {
need_cmd python3 need_cmd python3
need_cmd curl need_cmd curl
local meta_file tag bundle_url image_url local meta_file tag bundle_url
TMP_DIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
meta_file="$TMP_DIR/release.json" meta_file="$TMP_DIR/release.json"
trap cleanup_tmp_dir EXIT trap cleanup_tmp_dir EXIT
@@ -409,7 +393,6 @@ main() {
mapfile -t release_info < <(latest_tag_and_bundle_url "$meta_file") mapfile -t release_info < <(latest_tag_and_bundle_url "$meta_file")
tag="${release_info[0]:-}" tag="${release_info[0]:-}"
bundle_url="${release_info[1]:-}" bundle_url="${release_info[1]:-}"
image_url="${release_info[2]:-}"
if [ -z "$tag" ] || [ -z "$bundle_url" ]; then if [ -z "$tag" ] || [ -z "$bundle_url" ]; then
echo "Error: latest release metadata is incomplete." echo "Error: latest release metadata is incomplete."
@@ -425,17 +408,6 @@ main() {
pin_compose_app_image "$tag" pin_compose_app_image "$tag"
if [ -z "$image_url" ]; then
echo "Error: release image asset is missing"
exit 1
fi
curl -fL "$image_url" -o "$TMP_DIR/inventarsystem-image-$tag.tar.gz"
sudo docker load -i "$TMP_DIR/inventarsystem-image-$tag.tar.gz" >/dev/null
# Tagge das geladene Gitea-Image als latest
sudo docker tag "git.invario-software.eu/invario/inventarsystem:$tag" "git.invario-software.eu/invario/inventarsystem:latest" >/dev/null 2>&1 || true
if [ ! -f "$PROJECT_DIR/start.sh" ]; then if [ ! -f "$PROJECT_DIR/start.sh" ]; then
echo "Error: release bundle is missing start.sh" echo "Error: release bundle is missing start.sh"
exit 1 exit 1
+2 -1
View File
@@ -16,4 +16,5 @@ openpyxl
cryptography>=42.0.0 cryptography>=42.0.0
pywebpush pywebpush
py-vapid>=1.9.0 py-vapid>=1.9.0
beautifulsoup4 beautifulsoup4
pywebpush
+7 -44
View File
@@ -6,7 +6,6 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env" ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem" APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml" RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO="" SUDO=""
@@ -279,52 +278,19 @@ EOF
fi fi
} }
ensure_app_image_loaded() { ensure_app_image_ready() {
if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
return 0 return 0
fi fi
local image_archive echo "Attempting to pull registry image: $APP_IMAGE_VALUE"
image_archive="$(find_local_dist_image_archive || true)" if docker pull "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
if [ -n "$image_archive" ]; then
echo "Loading app image from local dist artifact: $image_archive"
if docker load -i "$image_archive" >/dev/null 2>&1 && docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
return 0
fi
echo "Warning: failed to load expected app image from $image_archive"
fi
echo "Error: local app image not found: $APP_IMAGE_VALUE"
echo "Run ./update.sh so the nightly updater loads the release image first."
exit 1
}
find_local_dist_image_archive() {
local tag archive
if [ ! -d "$DIST_DIR" ]; then
return 1
fi
tag="${APP_IMAGE_VALUE##*:}"
for archive in \
"$DIST_DIR/inventarsystem-image-$tag.tar.gz" \
"$DIST_DIR/inventarsystem-image-$tag.tar" \
"$DIST_DIR/inventarsystem-image.tar.gz" \
"$DIST_DIR/inventarsystem-image.tar"; do
if [ -f "$archive" ]; then
echo "$archive"
return 0
fi
done
archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)"
if [ -n "$archive" ]; then
echo "$archive"
return 0 return 0
fi fi
return 1 echo "Error: app image not found locally and pull failed: $APP_IMAGE_VALUE"
echo "Run ./update.sh to pull the latest release image from the registry."
exit 1
} }
configure_nuitka_mode() { configure_nuitka_mode() {
@@ -605,7 +571,6 @@ verify_stack_health() {
fi fi
compose_args+=(--env-file "$ENV_FILE") compose_args+=(--env-file "$ENV_FILE")
# Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do while [[ $retry_count -lt 2 ]]; do
echo "Waiting for containers to become healthy... (attempt $((retry_count + 1))/2)" echo "Waiting for containers to become healthy... (attempt $((retry_count + 1))/2)"
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
@@ -623,7 +588,6 @@ verify_stack_health() {
sleep 2 sleep 2
done done
# First failure: attempt recovery by restarting containers
if [[ $retry_count -eq 0 ]]; then if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..." echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true docker compose "${compose_args[@]}" ps || true
@@ -636,7 +600,6 @@ verify_stack_health() {
fi fi
done done
# Final failure
echo "Error: stack health check failed after restart attempt." echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
@@ -654,7 +617,7 @@ resolve_app_image
configure_host_ports configure_host_ports
ensure_min_docker_disk_space ensure_min_docker_disk_space
detect_server_capacity detect_server_capacity
ensure_app_image_loaded ensure_app_image_ready
write_env_file write_env_file
write_runtime_compose_override write_runtime_compose_override
+31 -151
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
# Release-only updater for Docker deployment. # Release-only updater for Docker deployment.
# Updates are pulled exclusively from Gitea Releases assets. # Updates pull the deployment bundle from Gitea and the Docker Image via 'docker pull'.
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$PROJECT_DIR/logs" LOG_DIR="$PROJECT_DIR/logs"
@@ -11,13 +11,10 @@ STATE_FILE="$PROJECT_DIR/.release-version"
REPO_SLUG="Invario/Inventarsystem" REPO_SLUG="Invario/Inventarsystem"
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest" API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz" BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env" ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem" APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
DIST_DIR="$PROJECT_DIR/dist"
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}" MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
MODE="release" MODE="release"
mkdir -p "$LOG_DIR" mkdir -p "$LOG_DIR"
@@ -109,36 +106,6 @@ ensure_min_root_disk_space() {
fi fi
} }
cleanup_old_dist_artifacts() {
local keep_count
keep_count="$DIST_KEEP_COUNT"
if [ ! -d "$DIST_DIR" ]; then
return 0
fi
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}')
if [ "${#archives[@]}" -le "$keep_count" ]; then
return 0
fi
local index old_archive deleted=0
for (( index=keep_count; index<${#archives[@]}; index++ )); do
old_archive="${archives[$index]}"
if rm -f "$old_archive"; then
deleted=$((deleted + 1))
fi
done
if [ "$deleted" -gt 0 ]; then
log_message "Cleaned up $deleted old dist image archive(s)"
fi
}
cleanup_docker_dangling_images() { cleanup_docker_dangling_images() {
if docker image prune -f >> "$LOG_FILE" 2>&1; then if docker image prune -f >> "$LOG_FILE" 2>&1; then
log_message "Cleaned up dangling Docker images" log_message "Cleaned up dangling Docker images"
@@ -153,7 +120,6 @@ Usage: $0 [options]
Options: Options:
--multitenant Use docker-compose-multitenant.yml (default) --multitenant Use docker-compose-multitenant.yml (default)
development Install development build from Gitea Registry or local dist
-h, --help Show this help message -h, --help Show this help message
EOF EOF
} }
@@ -165,10 +131,6 @@ parse_args() {
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
development|dev)
MODE="development"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -216,14 +178,12 @@ create_backup() {
} }
fetch_release_metadata() { fetch_release_metadata() {
local meta_file local meta_file="$1"
meta_file="$1"
curl -fsSL "$API_URL" -o "$meta_file" curl -fsSL "$API_URL" -o "$meta_file"
} }
parse_latest_tag() { parse_latest_tag() {
local meta_file local meta_file="$1"
meta_file="$1"
python3 - <<'PY' "$meta_file" python3 - <<'PY' "$meta_file"
import json, sys import json, sys
with open(sys.argv[1], 'r', encoding='utf-8') as f: with open(sys.argv[1], 'r', encoding='utf-8') as f:
@@ -233,9 +193,8 @@ PY
} }
parse_asset_url() { parse_asset_url() {
local meta_file asset_name local meta_file="$1"
meta_file="$1" local asset_name="$2"
asset_name="$2"
python3 - <<'PY' "$meta_file" "$asset_name" python3 - <<'PY' "$meta_file" "$asset_name"
import json, sys import json, sys
meta_file, asset_name = sys.argv[1], sys.argv[2] meta_file, asset_name = sys.argv[1], sys.argv[2]
@@ -248,31 +207,6 @@ for asset in data.get('assets', []):
PY PY
} }
load_release_image() {
local meta_file tag image_asset image_url tmp_dir archive
meta_file="$1"
tag="$2"
image_asset="${APP_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
image_url="$(parse_asset_url "$meta_file" "$image_asset")"
if [ -z "$image_url" ]; then
log_message "ERROR: Release image asset not found: $image_asset"
return 1
fi
tmp_dir="$(mktemp -d)"
archive="$tmp_dir/$image_asset"
trap 'rm -rf "${tmp_dir:-}"' RETURN
log_message "Loading app image from release asset $image_asset"
curl -fL "$image_url" -o "$archive"
docker load -i "$archive" >> "$LOG_FILE" 2>&1
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
trap - RETURN
}
refresh_runtime_scripts_from_main() { refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url local start_url stop_url restart_url update_url
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh" start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
@@ -285,61 +219,13 @@ refresh_runtime_scripts_from_main() {
curl -fsSL "$restart_url" -o "$PROJECT_DIR/restart.sh" || log_message "WARNING: Could not refresh restart.sh from main" curl -fsSL "$restart_url" -o "$PROJECT_DIR/restart.sh" || log_message "WARNING: Could not refresh restart.sh from main"
curl -fsSL "$update_url" -o "$PROJECT_DIR/update.sh" || log_message "WARNING: Could not refresh update.sh from main" curl -fsSL "$update_url" -o "$PROJECT_DIR/update.sh" || log_message "WARNING: Could not refresh update.sh from main"
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" 2>/dev/null || true
}
find_local_dist_image_archive() {
local tag="$1"
local archive
if [ ! -d "$DIST_DIR" ]; then
return 1
fi
for archive in \
"$DIST_DIR/inventarsystem-image-$tag.tar.gz" \
"$DIST_DIR/inventarsystem-image-$tag.tar" \
"$DIST_DIR/inventarsystem-image.tar.gz" \
"$DIST_DIR/inventarsystem-image.tar"; do
if [ -f "$archive" ]; then
echo "$archive"
return 0
fi
done
archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)"
if [ -n "$archive" ]; then
echo "$archive"
return 0
fi
return 1
}
load_local_dist_image() {
local tag="$1"
local archive
archive="$(find_local_dist_image_archive "$tag" || true)"
if [ -z "$archive" ]; then
return 1
fi
log_message "Loading app image from local dist artifact: $archive"
if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
return 0
fi
log_message "WARNING: Failed to load local dist artifact: $archive"
return 1
} }
download_and_extract_bundle() { download_and_extract_bundle() {
local url tmp_dir archive local url="$1"
url="$1" local tmp_dir="$2"
tmp_dir="$2" local archive="$tmp_dir/$BUNDLE_ASSET"
archive="$tmp_dir/$BUNDLE_ASSET"
curl -fL "$url" -o "$archive" curl -fL "$url" -o "$archive"
tar -xzf "$archive" -C "$tmp_dir" tar -xzf "$archive" -C "$tmp_dir"
@@ -375,19 +261,15 @@ download_and_extract_bundle() {
# Ensure executable permissions on all copied scripts # Ensure executable permissions on all copied scripts
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json" cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
log_message "Installed default config.json from release bundle" log_message "Installed default config.json from release bundle"
fi fi
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
} }
deploy() { deploy() {
local tag="$1" local tag="$1"
local meta_file="$2"
local app_image="${APP_IMAGE_REPO}:${tag}" local app_image="${APP_IMAGE_REPO}:${tag}"
local compose_path local compose_path
@@ -410,17 +292,14 @@ EOF
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE" printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
fi fi
if ! load_local_dist_image "$tag"; then log_message "Pulling Gitea Registry image $app_image"
if ! load_release_image "$meta_file" "$tag"; then if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
log_message "Falling back to tagged Gitea Registry image $app_image" log_message "Falling back to local Docker build for $app_image"
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
log_message "Falling back to local Docker build for $app_image"
docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
fi
fi
fi fi
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 # Image wurde oben gepullt, Stack hochfahren
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
} }
@@ -460,9 +339,12 @@ cleanup_server_space() {
else else
log_message "WARNING: Docker system prune failed" log_message "WARNING: Docker system prune failed"
fi fi
# Clean up old dist artifacts # Delete legacy dist folder if it exists
cleanup_old_dist_artifacts if [ -d "$PROJECT_DIR/dist" ]; then
# Clean up log files older than 7 days rm -rf "$PROJECT_DIR/dist" || true
log_message "Legacy dist folder removed"
fi
# Clean up old log files older than 7 days
if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then
log_message "Old log files (older than 7 days) cleaned up" log_message "Old log files (older than 7 days) cleaned up"
else else
@@ -511,17 +393,14 @@ EOF
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE" printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
fi fi
# Try local dist first, then pull from Gitea Registry log_message "Attempting to pull development image $app_image"
if ! load_local_dist_image "$tag"; then if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
log_message "Attempting to pull development image $app_image" log_message "ERROR: Could not obtain development image $app_image"
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then exit 1
log_message "ERROR: Could not obtain development image $app_image"
exit 1
fi
fi fi
# Bring up stack # Bring up stack
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 || true docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
if ! verify_stack_health; then if ! verify_stack_health; then
@@ -581,7 +460,7 @@ EOF
if [ "$current_tag" = "$latest_tag" ]; then if [ "$current_tag" = "$latest_tag" ]; then
log_message "Already on latest release ($latest_tag). Refreshing containers from prebuilt image." log_message "Already on latest release ($latest_tag). Refreshing containers from prebuilt image."
deploy "$latest_tag" "$meta_file" deploy "$latest_tag"
if verify_stack_health; then if verify_stack_health; then
log_message "Container refresh completed" log_message "Container refresh completed"
else else
@@ -611,18 +490,19 @@ EOF
log_message "Updating from release $latest_tag" log_message "Updating from release $latest_tag"
download_and_extract_bundle "$bundle_url" "$tmp_dir" download_and_extract_bundle "$bundle_url" "$tmp_dir"
refresh_runtime_scripts_from_main refresh_runtime_scripts_from_main
deploy "$latest_tag" "$meta_file" deploy "$latest_tag"
if ! verify_stack_health; then if ! verify_stack_health; then
log_message "ERROR: Updated stack failed health check" log_message "ERROR: Updated stack failed health check"
exit 1 exit 1
fi fi
echo "$latest_tag" > "$STATE_FILE" echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag" log_message "Update completed successfully to release $latest_tag"
sudo ./opt/Inventarsystem/restart.sh if [ -x "./opt/Inventarsystem/restart.sh" ]; then
sudo ./opt/Inventarsystem/restart.sh
fi
echo "Restart of the Server Completed" echo "Restart of the Server Completed"
} }