Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2d703bc7 | |||
| 8e6dd17243 | |||
| 2124ca65b2 | |||
| bc86dc4a0d | |||
| a49ed98ed7 | |||
| 4d9bb62907 | |||
| 8251cd9bfd | |||
| 3ed3148b8a | |||
| 27b265eaf5 | |||
| 3571bb6f6d | |||
| b037434e89 | |||
| ad14499df0 | |||
| 6f50fb2263 | |||
| a1c5a78b20 | |||
| 38320f488a | |||
| 2aee36cc92 |
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user