Compare commits

...

57 Commits

Author SHA1 Message Date
Aiirondev_dev c8f9c87ba4 Refactor MongoClient import: standardize import statements across push_notifications, tenant, and verify_audit_chain modules 2026-04-29 00:05:55 +02:00
Aiirondev_dev c2bb015b80 Enhance tenant database access: implement context-aware database retrieval for user operations 2026-04-28 23:48:41 +02:00
Aiirondev_dev 35c4e9c6f6 Enhance admin management: improve username case handling and return status for admin updates 2026-04-28 23:35:58 +02:00
Aiirondev_dev 3c5ee65b03 Enhance username generation: implement unique username creation from the first two letters of the first and last name 2026-04-28 23:27:28 +02:00
Aiirondev_dev 0edb87c347 Enhance user registration: implement generation of truly anonymized usernames for new users 2026-04-28 23:20:59 +02:00
Aiirondev_dev 678cc61cef Enhance Docker Compose and startup script: implement auto-scaling settings based on server capacity and environment variables for worker configuration 2026-04-28 23:05:55 +02:00
Aiirondev_dev f26ab5c5fb Enhance user retrieval: implement tenant-aware user search with fallback to default and tenant-specific databases 2026-04-28 22:59:14 +02:00
Aiirondev_dev abb09fbdb5 Enhance user authentication: improve tenant database handling and implement fallback user search across tenant databases 2026-04-28 22:26:02 +02:00
Aiirondev_dev 96f5b9e3cd Enhance tutorial functionality: add tooltip toggle feature and improve tooltip display across the system 2026-04-28 21:59:26 +02:00
Aiirondev_dev 3ab2b075da Enhance tenant database management: integrate TenantContext for dynamic database naming and improve database drop functionality 2026-04-28 21:29:06 +02:00
Aiirondev_dev 821d4d0ad8 Enhance user authentication: improve password validation and tenant context handling 2026-04-28 21:21:48 +02:00
Aiirondev_dev 6cc8dabf72 Enhance runtime override generation: add app image retrieval and improve port handling 2026-04-28 20:55:58 +02:00
Aiirondev_dev 0d7fa2c511 Enhance tenant management script: improve port parsing and runtime override generation 2026-04-28 20:38:19 +02:00
Aiirondev_dev 4a18460178 Enhance tenant management script: add functionality to remove tenant ports and update runtime port configuration 2026-04-28 19:00:08 +02:00
Aiirondev_dev d2b3d4f6ea Enhance tenant management script: improve tenant listing functionality and handle missing config file 2026-04-28 18:44:47 +02:00
Aiirondev_dev f3d327f28f Enhance tenant management scripts: add runtime port update functionality and handle occupied ports 2026-04-28 18:18:04 +02:00
Aiirondev_dev 2b05d8c952 Refactor manage-tenant script: improve parameter handling and ensure default values 2026-04-28 17:54:43 +02:00
Aiirondev_dev 6cb41c1277 Enhance tenant management script: add help option to display usage instructions 2026-04-28 17:49:44 +02:00
Aiirondev_dev f0d02d6af1 Refactor restart script: streamline Docker compose argument handling and improve readability 2026-04-28 17:20:10 +02:00
Aiirondev_dev 6f5e24104b Refactor restart script: improve Docker container restart logic and update completion messages 2026-04-28 17:06:37 +02:00
Aiirondev_dev 43406c29d1 Enhance tenant management script: switch to bash, enforce strict mode, and validate port input 2026-04-28 16:54:42 +02:00
Aiirondev_dev 7873c45cfc Refactor multi-tenant deployment: update port handling in scripts, add tenant port registration, and enhance Docker configurations
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:36:55 +02:00
Aiirondev_dev 14c4192306 Refactor Docker setup: remove Nginx and TLS configurations, update service dependencies, and adjust health check endpoints
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:41:10 +02:00
Aiirondev_dev 1efc7e01be Update HTTP and HTTPS port configuration in .docker-build.env
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:38:44 +02:00
Aiirondev_dev d567ba583b Update port configuration for multi-tenant deployment to avoid conflicts
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:21:34 +02:00
Aiirondev_dev 5a5af5375d Add missing Nginx configuration for SSL and proxy settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:17:33 +02:00
Aiirondev_dev a60eb6eebf Update health check endpoint in verify_stack_health function in start.sh and update.sh
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:55:29 +02:00
Aiirondev_dev 23b7a4cd2f Remove obsolete configuration file config.yml 2026-04-26 21:38:02 +02:00
Aiirondev_dev e6fd485049 Update service configuration in config.yml and simplify cloudflared command in docker-compose 2026-04-26 17:01:20 +02:00
Aiirondev_dev 15d9eba987 Fix server block syntax in Nginx configuration in update.sh 2026-04-26 16:34:42 +02:00
Aiirondev_dev 05d6d299da Enhance scheduler lock file handling and add health check endpoint 2026-04-26 16:17:29 +02:00
Aiirondev_dev ab9db1211c Add server cleanup function to update.sh for Docker and log management
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:51:14 +02:00
Aiirondev_dev 45b69e5ddb Update HTTPS port configuration and add cloudflared setup in Docker Compose 2026-04-26 15:35:29 +02:00
Aiirondev_dev 4971bc859b Add disk space checks and cleanup functions in start.sh and update.sh 2026-04-24 21:29:39 +02:00
Aiirondev_dev b7f55b0de0 Remove custom command for MongoDB service in Docker Compose 2026-04-24 21:18:53 +02:00
Aiirondev_dev 9c24e79bba Add retry mechanism for Docker Compose startup in start.sh 2026-04-24 21:13:31 +02:00
Aiirondev_dev 79c325329c Refactor cloudflared configuration to use external config file and update command syntax 2026-04-24 21:05:21 +02:00
Aiirondev_dev 8f81ffb4c5 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 20:58:09 +02:00
Aiirondev_dev 1d7692ea01 Add cloudflared service and configure tunnel profile in Docker Compose 2026-04-24 20:56:15 +02:00
Aiirondev_dev 038390b8cd Add multi-tenant configuration support with dynamic module enabling 2026-04-24 09:16:04 +02:00
Aiirondev_dev f2c1dc2ba5 Implement session validation for active users before request processing
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:00:22 +02:00
Aiirondev_dev 3eeae76e6c Remove Web directory volume mount from Docker Compose files for app service 2026-04-23 22:48:23 +02:00
Aiirondev_dev 0228e6cb1d Add working directory and command configuration for app service in Docker Compose files 2026-04-23 22:41:52 +02:00
Aiirondev_dev 4c59cca1a4 Add missing nginx configuration for SSL support and error handling 2026-04-23 22:36:01 +02:00
Aiirondev_dev 8412ae76ee Fix volume mount for Web directory in Docker Compose files: remove read-only flag for better accessibility 2026-04-23 22:32:01 +02:00
Aiirondev_dev ec4483c415 Enhance backup and restore scripts: update database name and add support for multi-tenant configurations 2026-04-23 22:26:54 +02:00
Aiirondev_dev 52656c715e Enhance mobile library loading: implement virtualization for improved performance and memory management on mobile devices
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:05:52 +02:00
Aiirondev_dev f6755aad42 Enhance mobile library loading: implement dynamic item rendering and lazy loading for improved performance on mobile devices 2026-04-23 21:50:49 +02:00
Aiirondev_dev cbbcc09fc2 Refactor navbar and search form styles for improved layout: adjust flex properties and widths for better responsiveness 2026-04-22 19:37:39 +02:00
Aiirondev_dev aa8912e8b7 Enhance navbar functionality: add data attribute for fixed navigation items and improve overflow detection logic 2026-04-22 17:34:19 +02:00
Aiirondev_dev 68488598af Enhance navigation links for borrowed items and tutorials: add conditional rendering for 'Meine Ausleihen' and 'Tutorial' links based on user permissions 2026-04-22 17:26:44 +02:00
Aiirondev_dev 6e8bb8236e Enhance navbar overflow functionality: refactor initNavbarOverflow to accept options for more toggle ID and improve handling of hidden navigation items 2026-04-22 16:50:46 +02:00
Aiirondev_dev bd92d60d08 Refine navbar styles for improved responsiveness: adjust padding, gap, and font sizes for various screen sizes 2026-04-22 16:39:29 +02:00
Aiirondev_dev 4991587d99 Enhance safe area support for iOS devices: update CSS for navbar and body to respect safe area insets and improve layout on mobile screens 2026-04-22 16:25:45 +02:00
Aiirondev_dev 2b8bf0b7d5 Update Docker configuration and scripts for multi-tenant support: change app image, adjust port settings, and clean up logs 2026-04-21 23:20:42 +02:00
Aiirondev_dev 9ae316cdd9 Enhance multi-tenant support: update scripts to handle multi-tenant Docker compose files and secure admin credentials 2026-04-21 16:57:43 +02:00
Aiirondev_dev 12f62e5146 Change Docker image for tenant-manager service and update entrypoint to use sh; modify shebang in manage-tenant.sh to use sh 2026-04-21 16:26:48 +02:00
31 changed files with 1982 additions and 654 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/inventarsystem:latest
INVENTAR_HTTP_PORT=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
+6
View File
@@ -0,0 +1,6 @@
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ghcr.io/aiirondev/legendary-octo-garbanzo:latest
build: null
+33 -32
View File
@@ -158,42 +158,17 @@ jobs:
- name: Create release-only docker bundle
run: |
mkdir -p release-bundle
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest}
pull_policy: never
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
container_name: inventarsystem-app
restart: unless-stopped
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on:
mongodb:
condition: service_healthy
- mongodb
- redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +186,33 @@ jobs:
- app_backups:/data/backups
- app_logs:/data/logs
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongodb_data:
app_uploads:
@@ -219,9 +221,9 @@ jobs:
app_qrcodes:
app_backups:
app_logs:
redis_data:
EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
@@ -232,7 +234,6 @@ jobs:
# Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
cp docker/nginx/multitenant.conf release-bundle/docker/nginx/multitenant.conf
cp manage-tenant.sh release-bundle/manage-tenant.sh
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
+1 -1
View File
@@ -14,7 +14,7 @@ Die optimierte Multi-Tenant-Architektur unterstützt **mehrere isolierte Instanz
└─────────────────────────────────────────────────────────────┘
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ App :8001 │ │ App :8002 │ │ App :8003
│ App :10000 │ │ App :10002 │ │ App :10004
│ schule1 │ │ schule2 │ │ schule3 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │
+26
View File
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
---
## Schul-Konfiguration pro Tenant
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
```json
{
"tenants": {
"schule1": {
"modules": {
"library": { "enabled": true },
"student_cards": { "enabled": false }
}
},
"schule2": {
"modules": {
"library": { "enabled": false }
}
}
}
}
```
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
---
## Support & Debugging
**Fragen?**
+39 -5
View File
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
return None
@app.before_request
def _enforce_active_session_user():
endpoint = request.endpoint or ''
if endpoint == 'static' or endpoint.startswith('static'):
return None
username = session.get('username')
if not username:
return None
user = us.get_user(username)
if user:
return None
session.clear()
if request.path.startswith('/api/') or request.is_json:
return jsonify({'ok': False, 'message': 'Sitzung ungültig. Bitte erneut anmelden.'}), 401
flash('Ihre Sitzung ist nicht mehr gültig. Bitte erneut anmelden.', 'error')
return redirect(url_for('login'))
def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -1291,9 +1313,17 @@ def _initialize_scheduler():
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception:
pass # If we can't clean up, continue anyway
except Exception as e:
app.logger.warning(f"Could not clean up scheduler lock file: {e}")
# Always try to remove lock file on startup (extra safety)
try:
if os.path.exists(scheduler_lock_path):
os.remove(scheduler_lock_path)
app.logger.info("Scheduler lock file removed on startup.")
except Exception as e:
app.logger.warning(f"Could not remove scheduler lock file on startup: {e}")
try:
# Try to create the lock file - only succeeds if it doesn't exist
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
@@ -1302,7 +1332,7 @@ def _initialize_scheduler():
except FileExistsError:
should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
@@ -7086,7 +7116,7 @@ def register():
name = (request.form.get('name') or '').strip()
last_name = (request.form.get('last-name') or '').strip()
# Always generate username from abbreviation logic and auto-extend on collisions.
# Generate a username from the first 2 letters of first and last name.
username = us.build_unique_username_from_name(name, last_name)
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
@@ -10784,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
# PUSH NOTIFICATION API ENDPOINTS
# ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push():
"""
-1
View File
@@ -1 +0,0 @@
[2026-04-19 20:20:39,502] ERROR in app: Error during scheduler shutdown: name '__file__' is not defined
+1 -1
View File
@@ -6,13 +6,13 @@ Handles Web Push notifications for users via Service Workers
import os
import json
import datetime
from pymongo import MongoClient
from bson import ObjectId
import requests
import hashlib
import logging
import settings as cfg
from settings import MongoClient
logger = logging.getLogger(__name__)
+2 -1
View File
@@ -13,5 +13,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
+52 -2
View File
@@ -14,6 +14,7 @@ import os
import json
import atexit
from threading import Lock
from flask import has_request_context
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
@@ -157,8 +158,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
class _TenantAwareBool:
def __init__(self, module_name, default):
self.module_name = module_name
self.default = bool(default)
def resolve(self):
try:
from tenant import is_tenant_module_enabled
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
except Exception:
return self.default
def __bool__(self):
return self.resolve()
def __int__(self):
return int(self.resolve())
def __str__(self):
return 'True' if self.resolve() else 'False'
def __repr__(self):
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
@@ -222,8 +251,29 @@ class _MongoClientProxy:
return getattr(self._client, name)
def __getitem__(self, name):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client[ctx.db_name]
except Exception:
pass
return self._client[name]
def get_database(self, name=None, *args, **kwargs):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name is None or name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client.get_database(ctx.db_name, *args, **kwargs)
except Exception:
pass
return self._client.get_database(name, *args, **kwargs)
def __enter__(self):
return self
+59
View File
@@ -34,6 +34,25 @@
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
html {
/* Ensure viewport-fit is respected */
viewport-fit: cover;
}
body {
/* Respect safe areas on mobile devices */
position: relative;
}
/* Future-proof: Support for various viewport and safe-area features */
@supports (padding: max(0px)) {
body {
/* Ensure body padding never conflicts with safe areas */
padding: 0;
}
}
/* Make inputs visible in dark mode */
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
@@ -172,11 +191,16 @@ select:focus {
box-shadow: none;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
/* Safe area insets for iPad notches and Dynamic Island */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
.navbar-brand {
font-weight: 800;
letter-spacing: 0.02em;
/* Prevent navbar brand from extending into unsafe areas */
word-break: break-word;
}
@media (max-width: 900px) {
@@ -190,6 +214,41 @@ select:focus {
}
}
/* Mobile and tablet safe area handling for navbar */
@media (max-width: 992px) {
.navbar {
/* Ensure navbar spans full width but respects safe areas */
width: 100vw;
margin-left: calc(-50vw + 50%);
/* Safe area padding is already handled by navbar class */
}
.navbar .container-fluid {
width: 100%;
box-sizing: border-box;
/* Ensure padding accounts for safe areas on small screens */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
}
.navbar-collapse {
/* Ensure collapsed navbar respects safe areas */
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
}
}
/* iPad-specific landscape and portrait handling */
@media (max-height: 600px) and (orientation: landscape) {
.navbar {
padding-top: max(env(safe-area-inset-top, 0), 4px);
}
.navbar .container-fluid {
padding-bottom: 4px;
}
}
/* Edit and generate buttons with theme-aware text for better visibility */
.edit-button, .duplicate-button, .generate-qr-button {
color: var(--ui-text) !important; /* Override any existing color */
+298 -86
View File
@@ -169,31 +169,163 @@
background-color: var(--module-primary-color) !important;
position: sticky;
top: 0;
padding-top: env(safe-area-inset-top, 0); /* iOS Safe Area */
/* iOS Safe Area insets for notches and rounded corners */
padding-top: env(safe-area-inset-top, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
z-index: 1900;
}
.navbar .container-fluid {
gap: 10px;
padding-bottom: env(safe-area-inset-bottom, 0); /* Fallback */
gap: 8px;
/* Ensure container respects safe areas on all sides */
padding-left: max(env(safe-area-inset-left, 0), 12px);
padding-right: max(env(safe-area-inset-right, 0), 12px);
padding-bottom: env(safe-area-inset-bottom, 0);
}
.navbar-brand {
font-weight: bold;
font-size: 1.4rem;
font-size: 1.3rem;
padding-right: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.navbar-brand::before {
content: attr(data-icon);
margin-right: 8px;
margin-right: 6px;
}
.navbar-nav .nav-link {
font-size: 1rem;
font-size: 0.95rem;
font-weight: 600;
padding: 0.6rem 0.85rem;
border-radius: 8px;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: padding .18s ease, font-size .18s ease;
white-space: nowrap;
}
body.tutorial-tooltips-active [data-tutorial-tip] {
position: relative;
border-radius: 10px;
outline: 1px dashed rgba(59, 130, 246, 0.35);
outline-offset: 2px;
cursor: help;
}
body.tutorial-tooltips-active [data-tutorial-tip]::before {
content: '💡';
position: absolute;
top: 50%;
right: -26px;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
color: #0f172a;
font-size: 0.75rem;
pointer-events: none;
}
body.tutorial-tooltips-active [data-tutorial-tip]::after {
content: attr(data-tutorial-tip);
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
min-width: 220px;
max-width: 280px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22);
z-index: 9999;
white-space: normal;
}
body.tutorial-tooltips-active [data-tutorial-tip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(10px);
}
.tutorial-tooltip-indicator {
position: fixed;
top: 92px;
right: 16px;
z-index: 2000;
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
color: #0f172a;
font-size: 0.95rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
}
body.tutorial-tooltips-active .tutorial-tooltip-indicator {
display: inline-flex;
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
.navbar .container-fluid {
gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px);
padding-right: max(env(safe-area-inset-right, 0), 10px);
}
.navbar-brand {
font-size: 1.1rem;
padding-right: 8px;
}
.navbar-brand::before {
margin-right: 4px;
}
.navbar-nav .nav-link {
font-size: 0.85rem;
padding: 0.45rem 0.6rem;
}
}
/* Small phones responsive adjustments */
@media (max-width: 576px) {
.navbar .container-fluid {
gap: 4px;
padding-left: max(env(safe-area-inset-left, 0), 8px);
padding-right: max(env(safe-area-inset-right, 0), 8px);
}
.navbar-brand {
font-size: 0.95rem;
padding-right: 6px;
}
.navbar-brand::before {
display: none;
}
.navbar-nav .nav-link {
font-size: 0.75rem;
padding: 0.4rem 0.5rem;
}
}
.navbar.nav-compact .navbar-brand {
@@ -250,6 +382,7 @@
align-items: center;
margin-right: 10px;
width: min(420px, 42vw);
min-width: 0;
}
.function-search-form {
@@ -257,10 +390,13 @@
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.function-search-input {
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
@@ -281,6 +417,7 @@
}
.function-search-btn {
flex: 0 0 auto;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45);
@@ -483,9 +620,48 @@
margin-top: 4px;
}
.navbar-collapse {
width: 100%;
}
.navbar-collapse > .d-flex {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.function-search-wrap {
width: 100%;
max-width: 100%;
margin: 8px 0 10px;
flex: 1 1 100%;
}
.function-search-form {
width: 100%;
max-width: 100%;
flex-wrap: nowrap;
}
.function-search-input {
width: auto;
min-width: 0;
flex: 1 1 auto;
}
.function-search-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.navbar-text {
margin-right: 0 !important;
width: 100%;
}
.user-menu-wrap {
align-self: flex-start;
}
.navbar-nav .nav-item {
@@ -852,29 +1028,27 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}" data-tutorial-tip="Starten Sie hier mit dem Materialbestand und suchen Sie nach Artikeln.">Artikel</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Öffnen Sie das Tutorial, um das System Schritt für Schritt kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}" data-tutorial-tip="Hier können Sie neue Artikel ins Inventar einpflegen."> Hochladen</a>
</li>
{% endif %}
<li class="nav-item">
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
@@ -886,15 +1060,6 @@
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
@@ -982,29 +1147,27 @@
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item">
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
@@ -1016,15 +1179,6 @@
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %}
@@ -1602,8 +1756,15 @@
const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return;
function initNavbarOverflow(navList, navOverflowAnchor, options) {
if (!navList) return;
const moreToggleId = options && options.moreToggleId ? options.moreToggleId : '';
const moreToggle = moreToggleId ? document.getElementById(moreToggleId) : null;
const moreControlItem = moreToggle ? moreToggle.closest('li.nav-item.dropdown') : null;
const moreMenu = moreControlItem ? moreControlItem.querySelector(':scope > ul.dropdown-menu') : null;
const moreLabelDefault = moreToggle ? moreToggle.textContent.trim() : 'Mehr Optionen';
const moreMenuOriginal = moreMenu ? moreMenu.innerHTML : '';
const navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse');
@@ -1631,45 +1792,35 @@
return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) return false;
if (li.id === navOverflowAnchor.id) return false;
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return false;
if (li.dataset.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false;
if (moreControlItem && li === moreControlItem) return false;
return li.style.display !== 'none';
});
}
function clearOverflowControls() {
if (!navList) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
node.remove();
});
if (!moreMenu) return;
moreMenu.innerHTML = moreMenuOriginal;
if (moreControlItem) {
moreControlItem.style.display = '';
}
if (moreToggle) {
moreToggle.textContent = moreLabelDefault;
}
}
function restoreAllNavItems() {
if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){
if (li.id === navOverflowAnchor.id) return;
if (navOverflowAnchor && li.id === navOverflowAnchor.id) return;
if (li.dataset.overflowControl === 'true') return;
li.style.display = '';
});
clearOverflowControls();
}
function createOverflowControl() {
const li = document.createElement('li');
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
const toggleId = navOverflowAnchor.id + '-toggle';
const menuId = navOverflowAnchor.id + '-menu';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="' + toggleId + '" role="button" data-bs-toggle="dropdown" aria-expanded="false" aria-controls="' + menuId + '">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="' + toggleId + '" id="' + menuId + '"></ul>';
return li;
}
function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) {
return -1;
@@ -1752,33 +1903,70 @@
function rebuildOverflowControl(hiddenSources) {
clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
const controlLink = control.querySelector(':scope > a.nav-link');
if (controlLink) {
controlLink.textContent = '⋮ Weitere (' + hiddenSources.length + ')';
}
if (!moreMenu || hiddenSources.length === 0) return;
const overflowFragment = document.createDocumentFragment();
hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu);
appendSourceToOverflowMenu(source, overflowFragment);
});
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
if (trailingDivider) {
const overflowWrap = document.createElement('div');
overflowWrap.appendChild(overflowFragment);
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
if (trailingDivider && trailingDivider.parentElement) {
trailingDivider.parentElement.remove();
}
navList.insertBefore(control, navOverflowAnchor);
const overflowEntries = Array.from(overflowWrap.childNodes);
if (overflowEntries.length > 0) {
const marker = document.createElement('li');
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
const firstExistingNode = moreMenu.firstChild;
moreMenu.insertBefore(marker, firstExistingNode);
let insertAfter = marker;
overflowEntries.forEach(function(node){
moreMenu.insertBefore(node, insertAfter.nextSibling);
insertAfter = node;
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
moreMenu.insertBefore(divider, insertAfter.nextSibling);
if (moreToggle) {
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
}
}
if (moreControlItem) {
moreControlItem.style.display = '';
}
}
function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return;
if (!navList) return;
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
function isNavOverflowing(bufferPx) {
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
return collapseOverflow || containerOverflow || listOverflow;
}
applyCompactMode();
if (window.innerWidth < 992) {
// Desktop/tablet-large: keep complete navigation visible.
if (!isMobileViewport) {
restoreAllNavItems();
return;
}
// Mobile menu is collapsed: avoid hiding links preemptively.
if (navCollapse && !navCollapse.classList.contains('show')) {
restoreAllNavItems();
return;
}
@@ -1787,8 +1975,9 @@
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
@@ -1800,8 +1989,9 @@
rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
@@ -1839,12 +2029,12 @@
// Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor);
initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
}
// Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
}
})();
</script>
@@ -1885,6 +2075,28 @@
});
});
</script>
<script>
(function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator);
}
indicator.style.display = active ? 'inline-flex' : 'none';
}
applyTooltipMode(enabled);
})();
</script>
</body>
</html>
+237 -1
View File
@@ -208,12 +208,23 @@
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const items = container.querySelectorAll('.item-card');
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +247,229 @@ function setViewMode(mode) {
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
@@ -311,6 +545,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (e.target === this) this.style.display = 'none';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
+63
View File
@@ -189,6 +189,38 @@
font-size: 1rem;
}
.tutorial-tooltip-toggle {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
padding: 16px;
border-radius: 18px;
background: rgba(224, 242, 254, 0.9);
border: 1px solid #bae6fd;
}
.tutorial-tooltip-toggle label {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
color: #0f172a;
}
.tutorial-tooltip-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #2563eb;
}
.tooltip-description {
margin: 0;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.workflow-controls {
display: flex;
gap: 8px;
@@ -230,6 +262,13 @@
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
<div class="tutorial-tooltip-toggle">
<label for="toggleTutorialTooltips">
<input type="checkbox" id="toggleTutorialTooltips" />
Tutorial-Tooltips für das gesamte System aktivieren
</label>
<p class="tooltip-description">Wenn aktiviert, erscheinen auf allen Seiten erklärende Hinweise und Icons für die wichtigsten Navigationselemente.</p>
</div>
</section>
<div class="tutorial-grid">
@@ -438,8 +477,32 @@
renderStep();
});
const tooltipToggle = document.getElementById('toggleTutorialTooltips');
const tooltipModeKey = 'inventarsystem_tutorial_tooltips_enabled_{{ username }}';
function setTooltipMode(enabled) {
localStorage.setItem(tooltipModeKey, enabled ? '1' : '0');
document.body.classList.toggle('tutorial-tooltips-active', enabled);
}
function loadTooltipMode() {
const saved = localStorage.getItem(tooltipModeKey);
const enabled = saved === '1';
if (tooltipToggle) {
tooltipToggle.checked = enabled;
}
setTooltipMode(enabled);
}
if (tooltipToggle) {
tooltipToggle.addEventListener('change', function () {
setTooltipMode(this.checked);
});
}
loadState();
renderStep();
loadTooltipMode();
})();
</script>
{% endblock %}
+104 -8
View File
@@ -10,28 +10,104 @@ Each tenant can support up to 20+ users with isolated data and resource pools.
from flask import request, g, has_request_context
from functools import wraps
import logging
import os
import re
import settings as cfg
logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {}
if isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
TENANT_REGISTRY.update(cfg.TENANT_CONFIGS)
def _get_nested_value(source, path, default=None):
current = source
for key in path:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
def _parse_port_from_host(host):
"""Parse host header and return (hostname, port) when a numeric port is present."""
if host.startswith('['):
# IPv6 with port: [::1]:10000
if ']:' in host:
host_part, _, port_part = host.rpartition(']:')
return host_part + ']', port_part
return host, None
if host.count(':') == 1:
hostname, port = host.split(':', 1)
if port.isdigit():
return hostname, port
return host, None
def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items():
if isinstance(config, dict) and config.get('port') is not None:
try:
configured_port = str(int(config.get('port')))
except (TypeError, ValueError):
continue
if configured_port == str(port):
return tenant_id
port_map = os.getenv('INVENTAR_TENANT_PORT_MAP', '').strip()
if port_map:
for mapping in re.split(r'[;,\s]+', port_map):
if '=' not in mapping:
continue
key, value = mapping.split('=', 1)
if key.strip() == str(port):
return value.strip()
return None
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
if tenant_id is None:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
return TENANT_REGISTRY.get('default', {}) or {}
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id)
enabled = _get_nested_value(config, ['modules', module_name, 'enabled'], default)
return bool(enabled)
class TenantContext:
"""
Manages current tenant context for request lifecycle.
Automatically resolves tenant from subdomain or request header.
Automatically resolves tenant from port, header, or subdomain.
"""
def __init__(self):
self.tenant_id = None
self.db_name = None
self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self):
"""
Resolve tenant from request context.
Priority: Header > Subdomain > Default
Priority: Header > Port mapping > Subdomain > Default
"""
if not has_request_context():
return None
@@ -40,10 +116,21 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
self.tenant_id = tenant_from_header
self.config = get_tenant_config(tenant_from_header)
return self._get_db_name(tenant_from_header)
# Priority 2: Subdomain extraction
# Priority 2: Port-based tenant mapping
host = request.host.lower()
_, port = _parse_port_from_host(host)
self.port = port
if port:
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
return self._get_db_name(tenant_from_port)
# Priority 3: Subdomain extraction
parts = host.split('.')
# Extract subdomain from host
@@ -56,11 +143,20 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
return self._get_db_name(potential_subdomain)
# Fallback to default tenant if no subdomain detected
self.tenant_id = 'default'
return self._get_db_name('default')
# Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
if 'default' in TENANT_REGISTRY:
self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default')
self.tenant_id = None
self.config = {}
self.db_name = cfg.MONGODB_DB
return self.db_name
def _get_db_name(self, tenant_id):
"""
@@ -125,8 +221,8 @@ def get_tenant_db(mongo_client):
ctx = get_tenant_context()
if ctx:
return ctx.get_database(mongo_client)
# Fallback to default database
return mongo_client['inventar_default']
# Fallback to configured default database
return mongo_client[cfg.MONGODB_DB]
def register_tenant(tenant_id, config=None):
+104 -64
View File
@@ -13,6 +13,8 @@ Provides methods for creating, validating, and retrieving user information.
import hashlib
import copy
import re
import secrets
import string
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
@@ -43,24 +45,33 @@ def _clean_name_fragment(value):
return cleaned
def _get_tenant_db(client):
"""Return the current tenant database for the request, or fall back to default."""
try:
from tenant import get_tenant_db
return get_tenant_db(client)
except Exception:
return client[cfg.MONGODB_DB]
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
"""Build a deterministic, non-personalized short alias from 2 letters each."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
return (first[:2] + last[:2]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
return combined[:4].title()
def build_username_from_name(first_name, last_name=''):
"""
Build a deterministic username abbreviation from first and last name.
Uses the same short alias logic (e.g. SimFri) and stores it lowercase.
Uses 2 letters from each name and stores it lowercase.
Args:
first_name (str): First name
@@ -75,32 +86,23 @@ def build_username_from_name(first_name, last_name=''):
def build_unique_username_from_name(first_name, last_name=''):
"""
Build a unique username based on the abbreviation logic.
If a collision occurs, increase the username by one additional letter
from the combined cleaned name until it is unique.
Build a unique username from the first 2 letters of the first name and
the first 2 letters of the last name.
"""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
combined = (first + last).lower()
base_username = (first[:2] + last[:2]).lower()
base_username = build_username_from_name(first_name, last_name)
if not combined:
combined = base_username or 'user'
if not base_username:
base_username = 'user'
start_len = len(base_username) if base_username else min(6, len(combined))
start_len = max(1, min(start_len, len(combined)))
if not get_user(base_username):
return base_username
# Main strategy: take one more letter on each collision.
for length in range(start_len, len(combined) + 1):
candidate = combined[:length]
if not get_user(candidate):
return candidate
# Fallback if full combined name is already taken repeatedly.
suffix = 2
while get_user(f"{combined}{suffix}"):
while get_user(f"{base_username}{suffix}"):
suffix += 1
return f"{combined}{suffix}"
return f"{base_username}{suffix}"
ACTION_PERMISSION_KEYS = (
@@ -310,7 +312,7 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
}
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': username}, {'$set': update_data})
@@ -325,7 +327,7 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
def get_favorites(username):
"""Return a list of favorite item ObjectId strings for the user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
@@ -339,7 +341,7 @@ def add_favorite(username, item_id):
"""Add an item to user's favorites (idempotent)."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
@@ -354,7 +356,7 @@ def remove_favorite(username, item_id):
"""Remove an item from user's favorites."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
@@ -417,13 +419,31 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name = cfg.MONGODB_DB
tenant_db = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
tenant_db = ctx.db_name or ctx.resolve_tenant()
db_name = tenant_db
except Exception:
pass
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
client.close()
return user
try:
hashed_password = hashing(password)
def find_user_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username, 'Password': hashed_password}) or users.find_one({'username': username, 'Password': hashed_password})
return find_user_in_db(db_name)
finally:
client.close()
return None
def add_user(
@@ -449,7 +469,7 @@ def add_user(
bool: True if user was added successfully, False if password was too weak
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
if not check_password_strength(password):
return False
@@ -471,7 +491,7 @@ def add_user(
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'last_name': '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -499,7 +519,7 @@ def student_card_exists(student_card_id):
if not normalized:
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
exists = users.find_one({'StudentCardId': normalized}) is not None
client.close()
@@ -512,7 +532,7 @@ def get_user_by_student_card(student_card_id):
if not normalized:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
found_user = users.find_one({'StudentCardId': normalized})
client.close()
@@ -530,11 +550,13 @@ def make_admin(username):
bool: True if user was promoted successfully
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': True}})
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close()
return True
return result.matched_count > 0
def remove_admin(username):
"""
@@ -547,11 +569,13 @@ def remove_admin(username):
bool: True if user was demoted successfully
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': False}})
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close()
return True
return result.matched_count > 0
def get_user(username):
"""
@@ -564,11 +588,31 @@ def get_user(username):
dict: User document or None if not found
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
return users_return
try:
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
user = find_in_db(ctx.db_name)
if user:
return user
except Exception:
pass
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
if user:
return user
return None
finally:
client.close()
def check_admin(username):
@@ -581,12 +625,8 @@ def check_admin(username):
Returns:
bool: True if user is an administrator, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
user = users.find_one({'Username': username})
client.close()
return user and user.get('Admin', False)
user = get_user(username)
return bool(user and user.get('Admin', False))
def update_active_ausleihung(username, id_item, ausleihung):
@@ -602,7 +642,7 @@ def update_active_ausleihung(username, id_item, ausleihung):
bool: True if successful
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}})
client.close()
@@ -620,7 +660,7 @@ def get_active_ausleihung(username):
dict: Active borrowing information or None
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
return user['active_ausleihung']
@@ -638,7 +678,7 @@ def has_active_borrowing(username):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'username': username})
@@ -669,14 +709,14 @@ def delete_user(username):
bool: True if user was deleted successfully, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': username})
client.close()
if result.deleted_count == 0:
# Try with different field name
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'Username': username})
client.close()
@@ -698,7 +738,7 @@ def update_active_borrowing(username, item_id, status):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
{'username': username},
@@ -731,7 +771,7 @@ def get_name(username):
str: String of name
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("name")
@@ -746,7 +786,7 @@ def get_last_name(username):
str: String of last_name
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("last_name")
@@ -763,7 +803,7 @@ def get_all_users():
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
all_users = list(users.find())
client.close()
@@ -787,7 +827,7 @@ def update_password(username, new_password):
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
# Hash the new password
@@ -820,7 +860,7 @@ def update_user_name(username, name, last_name):
try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
+1 -2
View File
@@ -4,9 +4,8 @@
import json
import sys
from pymongo import MongoClient
import settings as cfg
from settings import MongoClient
import audit_log as al
+10 -3
View File
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}"
DB_NAME="${DB_NAME:-inventar_default}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false
@@ -58,6 +59,8 @@ Options:
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
-h|--help Show this help and exit
EOF
}
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
MIN_KEEP="$2"; shift 2;;
--mode)
BACKUP_MODE="$2"; shift 2;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"; shift;;
-h|--help)
usage; exit 0;;
*)
@@ -102,12 +109,12 @@ log_message() {
init_docker_compose() {
if docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0
fi
if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/$COMPOSE_FILE")
return 0
fi
+4 -2
View File
@@ -1,9 +1,9 @@
{
"dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2",
"ver": "0.6.44",
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "localhost",
@@ -50,6 +50,8 @@
}
},
"tenants": {},
"allowed_extensions": [
"png", "jpg", "jpeg", "gif",
"hevc", "heif",
+24 -49
View File
@@ -1,19 +1,17 @@
# Multi-Tenant Optimized Docker Compose
# Supports running multiple isolated app instances per subdomain
# Supports running multiple isolated app instances with direct Docker host port binding
# Each instance: ~50MB base + 20-30MB per 20 users
#
# Usage:
# docker-compose -f docker-compose-multitenant.yml up -d
#
# Scale example: To support 10 tenants with 20 users each:
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
version: '3.9'
# Example: Start the stack and expose the app on host port 10000
# INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
services:
# Management Container for multi-tenant scripts
tenant-manager:
image: bash
image: docker:cli
container_name: tenant-manager
restart: "no"
profiles:
@@ -21,34 +19,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: ["bash", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./docker/nginx/acme:/etc/nginx/acme:ro
networks:
- inventar-net
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
environment:
NGINX_WORKER_PROCESSES: auto
NGINX_WORKER_CONNECTIONS: 1024
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
redis:
image: redis:7-alpine
@@ -71,9 +42,8 @@ services:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2 --journal --dbPath /data/db
ports:
- "27017:27017"
expose:
- "27017"
volumes:
- mongodb_data:/data/db
- ./docker/mongo:/docker-entrypoint-initdb.d:ro
@@ -94,6 +64,8 @@ services:
args:
PYTHON_VERSION: "3.13"
OPTIMIZATION_LEVEL: 2
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
restart: unless-stopped
security_opt:
- no-new-privileges:true
@@ -102,13 +74,14 @@ services:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks:
- inventar-net
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached
@@ -133,11 +106,16 @@ services:
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
# Performance Tuning
INVENTAR_WORKER_CLASS: gevent
INVENTAR_WORKERS: "4"
INVENTAR_THREADS: "2"
INVENTAR_WORKER_TIMEOUT: "30"
INVENTAR_WORKER_CONNECTIONS: "100"
INVENTAR_WORKER_CLASS: ${INVENTAR_WORKER_CLASS:-gevent}
INVENTAR_WORKERS: "${INVENTAR_WORKERS:-4}"
INVENTAR_THREADS: "${INVENTAR_THREADS:-2}"
INVENTAR_WORKER_TIMEOUT: "${INVENTAR_WORKER_TIMEOUT:-30}"
INVENTAR_WORKER_CONNECTIONS: "${INVENTAR_WORKER_CONNECTIONS:-100}"
# Auto-scaled runtime settings
INVENTAR_APP_CPUS: ${INVENTAR_APP_CPUS:-1.0}
INVENTAR_APP_MEM_LIMIT: ${INVENTAR_APP_MEM_LIMIT:-256m}
INVENTAR_APP_MEM_SWAP_LIMIT: ${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}
# Multi-Tenant
INVENTAR_MULTITENANT_ENABLED: "true"
@@ -158,9 +136,9 @@ services:
# Resource Limits (per instance)
# With 10 instances: each gets ~150MB, totaling ~1.5GB
# Adjust based on server resources
mem_limit: 256m
memswap_limit: 512m
cpus: "1.0"
mem_limit: "${INVENTAR_APP_MEM_LIMIT:-256m}"
memswap_limit: "${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}"
cpus: "${INVENTAR_APP_CPUS:-1.0}"
# Health check for load balancer
healthcheck:
@@ -193,9 +171,6 @@ volumes:
networks:
inventar-net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
# Scale guidance:
# 1 tenant (20 users): 1 app instance + redis + mongodb = ~500MB
+26 -72
View File
@@ -1,87 +1,41 @@
version: "3.8"
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
app:
build: .
container_name: inventory-app
restart: unless-stopped
environment:
- MONGO_URL=mongodb://mongodb:27017/inventar
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on:
app:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- mongodb
- redis
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
image: mongo:latest
container_name: mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
- mongo_data:/data/db
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
image: redis:alpine
container_name: redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
container_name: inventarsystem-app
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
security_opt:
- no-new-privileges:true
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_CACHE_DB: "1"
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
expose:
- "8000"
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
- ./config.yml:/etc/cloudflared/config.yml
- ./credentials.json:/etc/cloudflared/credentials.json
depends_on:
- app
volumes:
mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
mongo_data:
+8
View File
@@ -0,0 +1,8 @@
tunnel: homeserver
credentials-file: /etc/cloudflared/credentials.json
ingress:
- service: https://nginx:443
originRequest:
noTLSVerify: true
- service: http_status:404
+5 -4
View File
@@ -37,7 +37,8 @@ upstream inventar_backend {
# Main HTTPS server block
server {
listen 443 ssl http2;
listen 443 ssl;
http2 on;
server_name ~^(?<tenant>[a-z0-9-]+)?\.?example\.com$;
# SSL Configuration (wildcard certificate)
@@ -75,9 +76,9 @@ server {
application/x-javascript application/xml+rss application/json;
gzip_comp_level 5;
# Access/Error logs with tenant in filename
access_log /var/log/nginx/inventar_access_${tenant}.log combined buffer=32k;
error_log /var/log/nginx/inventar_error_${tenant}.log warn;
# Access/Error logs
access_log /var/log/nginx/inventar_access.log combined buffer=32k;
error_log /var/log/nginx/inventar_error.log warn;
# Root location - proxy to app with tenant context
location / {
+29 -10
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
pin_compose_app_image() {
local tag="$1"
local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Warning: $compose_file not found; cannot pin app image"
return 0
fi
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
if [ ! -f "$compose_file" ]; then
continue
fi
python3 - <<'PY' "$compose_file" "$tag"
python3 - <<'PY' "$compose_file" "$tag"
import re
import sys
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out)
PY
done
}
install_docker_if_missing() {
@@ -173,6 +175,8 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +203,14 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--legacy-db-name)
LEGACY_DB_NAME="$2"
shift 2
@@ -280,14 +292,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() {
local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0
fi
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
echo "Error: compose file not found: $compose_path"
exit 1
fi
echo "Waiting for Docker MongoDB to become ready..."
for i in $(seq 1 60); do
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
break
fi
sleep 2
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
fi
echo "Importing legacy backup into Docker MongoDB..."
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
echo "Legacy DB import completed."
}
@@ -454,8 +473,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
+315 -30
View File
@@ -1,4 +1,6 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others
@@ -7,52 +9,292 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1
fi
CONFIG_FILE="$PWD/config.json"
show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo ""
echo "Commands:"
echo " add <tenant_id> Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh restart-all"
echo " ./manage-tenant.sh -h"
exit 1
}
if [ -z "$1" ]; then
register_tenant_port() {
local tenant_id="$1"
local port="$2"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port"
import json, sys, os
path, tenant_id, port_str = sys.argv[1], sys.argv[2], sys.argv[3]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid != tenant_id:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
existing = tenants.get(tenant_id)
if existing is None or not isinstance(existing, dict):
existing = {}
existing['port'] = int(port_str)
tenants[tenant_id] = existing
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Registered tenant port {port_str} for {tenant_id}")
PY
then
echo "Tenant $tenant_id port $port registered in config.json"
else
echo "Failed to register tenant port $port for $tenant_id"
exit 1
fi
}
update_runtime_ports() {
local new_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local unique_ports=()
local first_port=""
if [ -n "$new_port" ]; then
if [ -f "$env_file" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
fi
if [ -n "$current_ports" ]; then
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
fi
port_list+=("$new_port")
for port in "${port_list[@]}"; do
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
unique_ports+=("$port")
fi
done
if [ ${#unique_ports[@]} -eq 0 ]; then
unique_ports=("$new_port")
fi
first_port="${unique_ports[0]}"
local ports_csv
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$first_port
INVENTAR_HTTP_PORTS=$ports_csv
EOF
else
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
fi
echo "Updated runtime env ports: $ports_csv"
fi
}
remove_tenant_port() {
local tenant_id="$1"
local config_path="$CONFIG_FILE"
local port
port="$(python3 - "$config_path" "$tenant_id" <<'PY'
import json, sys, os
path, tenant_id = sys.argv[1], sys.argv[2]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict) or tenant_id not in tenants or not isinstance(tenants[tenant_id], dict):
sys.exit(2)
removed = tenants.pop(tenant_id, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
if removed is not None:
port = removed.get('port')
if port is not None:
print(port)
PY
)"
local status=$?
if [ $status -eq 1 ]; then
echo "Error: config file not found: $config_path"
return 1
fi
if [ $status -eq 2 ]; then
return 2
fi
if [ -n "$port" ]; then
echo "$port"
fi
return 0
}
remove_runtime_port() {
local removed_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local new_ports=()
local first_port=""
if [ ! -f "$env_file" ]; then
return 0
fi
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
if [ -z "$current_ports" ]; then
return 0
fi
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
for port in "${port_list[@]}"; do
if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then
new_ports+=("$port")
fi
done
if [ ${#new_ports[@]} -eq 0 ]; then
sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file"
return 0
fi
first_port="${new_ports[0]}"
local ports_csv="$(IFS=,; echo "${new_ports[*]}")"
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
}
if [ -z "${1:-}" ]; then
show_help
fi
COMMAND=$1
TENANT_ID=$2
COMMAND="$1"
TENANT_ID="${2:-}"
case "$COMMAND" in
-h|--help)
show_help
;;
add)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
echo "Adding new tenant '$TENANT_ID'..."
# Add Nginx configuration
if [ -f "docker/nginx/multitenant.conf" ]; then
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..."
PORT_ARG="$3"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
fi
echo "Adding new tenant '$TENANT_ID'..."
# Initialize tenant database via Python inside container
echo "Initializing database for $TENANT_ID..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
import sys, re; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.users.insert_one({'username': 'admin', 'password': 'hashed_password_here', 'role': 'admin'})
print(f'Tenant {sys.argv[1]} database initialized.')
db = client[db_name]
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
if db.users.count_documents({'Username': 'admin'}) == 0:
db.users.insert_one({
'Username': 'admin',
'Password': hashed_pw,
'Admin': True,
'active_ausleihung': None,
'name': 'Admin',
'last_name': 'User',
'IsStudent': False,
'PermissionPreset': 'full_access',
'ActionPermissions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'PagePermissions': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
else
@@ -73,14 +315,28 @@ print(f'Tenant {sys.argv[1]} database initialized.')
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
import sys; sys.path.insert(0, '/app/Web'); from tenant import TenantContext; import settings; from pymongo import MongoClient
ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(f'{settings.MONGODB_DB}_{sys.argv[1]}')
client.drop_database(db_name)
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' removed successfully."
echo "Tenant '$TENANT_ID' database removed."
else
echo "Error: Application container not running."
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
fi
port_to_remove=""
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
fi
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
else
echo "Removal canceled."
@@ -117,17 +373,46 @@ print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
;;
list)
echo "Listing active tenants (Databases):"
echo "Listing configured tenants (config.json):"
if [ -f "$CONFIG_FILE" ]; then
python3 - "$CONFIG_FILE" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if isinstance(tenants, dict) and tenants:
for tid, conf in tenants.items():
port = conf.get('port') if isinstance(conf, dict) else None
if port is not None:
print(f'- {tid} (port {port})')
else:
print(f'- {tid}')
else:
print(' (no tenants configured)')
PY
else
echo " (config.json not found)"
fi
echo ""
echo "Listing active tenant databases (MongoDB):"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app/Web'); import settings; from pymongo import MongoClient
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
import sys
sys.path.insert(0, '/app/Web')
import settings
from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = f'{settings.MONGODB_DB}_'
prefix = 'inventar_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
for db in dbs:
print(f'- {db.replace(prefix, \"\")}')
"
if dbs:
for db in dbs:
print(f'- {db.replace(prefix, "")}')
else:
print(' (no tenant databases found)')
PY
else
echo "Error: Application container not running."
fi
+2 -1
View File
@@ -12,5 +12,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid>=1.9.0
+106 -4
View File
@@ -1,7 +1,109 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$SCRIPT_DIR"
"$SCRIPT_DIR/stop.sh"
"$SCRIPT_DIR/start.sh"
COMPOSE_FILE="docker-compose-multitenant.yml"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
write_runtime_override() {
local current_ports=""
local app_image=""
local ports=()
if [ -f "$ENV_FILE" ]; then
app_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
fi
fi
if [ -n "$app_image" ]; then
if [ -n "$current_ports" ]; then
mapfile -t ports < <(parse_port_list "$current_ports")
fi
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
image: $app_image
build: null
EOF
if [ ${#ports[@]} -gt 1 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports[@]}"; do
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
done
fi
else
rm -f "$RUNTIME_COMPOSE_OVERRIDE_FILE"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
write_runtime_override
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=( -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" )
fi
if [ -f "$ENV_FILE" ]; then
compose_args+=( --env-file "$ENV_FILE" )
fi
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
docker compose "${compose_args[@]}" up -d --build app
else
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
if ! docker compose "${compose_args[@]}" up -d --no-build app; then
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
docker compose "${compose_args[@]}" pull app
docker compose "${compose_args[@]}" up -d --no-build app
fi
fi
echo "Cleaning up unused Docker images..."
docker image prune -f
echo "App restart complete."
+14 -3
View File
@@ -6,12 +6,13 @@ LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH=""
BACKUP_DATE=""
DROP_DATABASE=false
RESTART_SERVICES=false
STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups"
@@ -56,6 +57,8 @@ Options:
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates
--help Show this help
@@ -68,12 +71,12 @@ EOF
setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose)
DOCKER_COMPOSE=(docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0
fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose)
DOCKER_COMPOSE=($SUDO docker compose -f "$SCRIPT_DIR/$COMPOSE_FILE")
return 0
fi
@@ -459,6 +462,14 @@ parse_args() {
RESTART_SERVICES=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list)
list_backups
exit 0
+251 -184
View File
@@ -7,17 +7,27 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="80"
HTTPS_PORT_VALUE="443"
HTTP_PORT_VALUE="10000"
HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose-multitenant.yml"
COMPOSE_PROFILES_VALUE=""
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
usage() {
cat <<EOF
@@ -26,6 +36,8 @@ Usage: $0 [options]
Options:
--no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment
--singletenant Use the legacy single-tenant compose deployment
-h, --help Show this help message
EOF
}
@@ -41,6 +53,14 @@ parse_args() {
CRON_SETUP_VALUE="1"
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help)
usage
exit 0
@@ -100,7 +120,11 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
install_docker_engine
if [ "$IS_ROOT" = "true" ]; then
install_docker_engine
else
missing+=(docker)
fi
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -124,14 +148,20 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
echo "ERROR: Missing dependencies: ${missing[*]}"
echo "Please install the missing tools or run this script as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then
$SUDO systemctl enable --now cron >/dev/null 2>&1 || true
systemctl enable --now cron >/dev/null 2>&1 || true
fi
fi
}
@@ -144,6 +174,11 @@ setup_boot_autostart_service() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping systemd autostart setup when not running as root."
return 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
@@ -185,6 +220,11 @@ setup_scheduled_jobs() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping cron job setup when not running as root."
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup"
return 0
@@ -195,101 +235,17 @@ setup_scheduled_jobs() {
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron
if [ "$(id -u)" -eq 0 ]; then
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
else
existing_cron="$($SUDO crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | $SUDO crontab -
fi
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00"
}
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$SCRIPT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0
fi
cn="${TLS_CN:-localhost}"
echo "No TLS certificates found. Generating self-signed certificate for CN=$cn"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$SCRIPT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
client_max_body_size 50M;
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
echo "Recreated missing nginx config at $config_path"
fi
}
ensure_runtime_config_json() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
@@ -306,7 +262,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "mongodb",
"port": 27017,
@@ -445,112 +401,210 @@ find_free_port() {
echo "$port"
}
stack_owns_host_port() {
local requested_port="$1"
local container_port="$2"
local mapped_port
mapped_port="$(docker compose --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
if [ -z "$mapped_port" ]; then
return 1
fi
mapped_port="${mapped_port##*:}"
[ "$mapped_port" = "$requested_port" ]
}
stop_host_nginx_services() {
local stopped_any=false
local service_name
if ! command -v systemctl >/dev/null 2>&1; then
return 1
fi
while IFS= read -r service_name; do
[ -z "$service_name" ] && continue
echo "Stopping host service $service_name to free web ports..."
$SUDO systemctl stop "$service_name" >/dev/null 2>&1 || true
stopped_any=true
done < <(systemctl list-units --type=service --state=active --no-pager 2>/dev/null | awk '{print $1}' | grep -E '(^nginx\.service$|nginx)' || true)
if [ "$stopped_any" = true ]; then
sleep 2
fi
if [ "$stopped_any" = true ]; then
return 0
fi
return 1
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
configure_host_ports() {
local requested_http requested_https
local requested_http
local requested_ports
local ports=()
local occupied_ports=()
requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if [ -z "$requested_http" ]; then
requested_http="80"
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if stack_owns_host_port "$requested_http" "80"; then
HTTP_PORT_VALUE="$requested_http"
elif port_in_use "$requested_http"; then
stop_host_nginx_services || true
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
requested_ports="$INVENTAR_HTTP_PORTS"
fi
if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http"
echo "Freed HTTP port $requested_http by stopping host nginx service"
else
HTTP_PORT_VALUE="$(find_free_port 8080)"
echo "HTTP port 80 is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
requested_ports="$INVENTAR_HTTP_PORT"
fi
if [ -n "$requested_ports" ]; then
mapfile -t ports < <(parse_port_list "$requested_ports")
fi
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
for port in "${ports[@]}"; do
if port_in_use "$port"; then
occupied_ports+=("$port")
fi
done
if [ ${#occupied_ports[@]} -gt 0 ]; then
if [ ${#ports[@]} -eq 1 ]; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port ${ports[0]} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
else
echo "Error: requested host port(s) already in use: ${occupied_ports[*]}"
echo "Remove the occupied port(s) from INVENTAR_HTTP_PORTS or stop the conflicting service."
exit 1
fi
fi
else
HTTP_PORT_VALUE="$requested_http"
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
fi
}
ensure_min_docker_disk_space() {
local docker_root available_kb available_mb
if ! command -v df >/dev/null 2>&1; then
return 0
fi
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
if [ -z "$docker_root" ]; then
docker_root="/var/lib/docker"
fi
if [ ! -d "$docker_root" ]; then
return 0
fi
available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
if [ -z "$available_kb" ]; then
return 0
fi
available_mb=$((available_kb / 1024))
if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
echo "Error: low disk space in Docker data root ($docker_root)."
echo "Available: ${available_mb} MB; required minimum: ${MIN_DOCKER_FREE_MB} MB"
echo "MongoDB may fail with 'No space left on device'. Free space and retry."
exit 1
fi
}
detect_server_capacity() {
local cpus mem_kb mem_gb app_workers app_cpus app_mem app_mem_swap
cpus="$(nproc 2>/dev/null || echo 1)"
mem_kb="$(awk '/MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null || awk '/MemTotal:/ {print $2; exit}' /proc/meminfo || echo 0)"
mem_gb=$(( (mem_kb + 1024*1024 - 1) / (1024*1024) ))
if [ "$cpus" -ge 8 ] && [ "$mem_gb" -ge 16 ]; then
app_workers=8
app_cpus="2.0"
app_mem="512m"
app_mem_swap="1024m"
elif [ "$cpus" -ge 4 ] && [ "$mem_gb" -ge 8 ]; then
app_workers=4
app_cpus="1.0"
app_mem="384m"
app_mem_swap="768m"
elif [ "$cpus" -ge 2 ] && [ "$mem_gb" -ge 4 ]; then
app_workers=2
app_cpus="0.75"
app_mem="320m"
app_mem_swap="640m"
else
app_workers=1
app_cpus="0.5"
app_mem="256m"
app_mem_swap="512m"
fi
requested_https=""
if [ -f "$ENV_FILE" ]; then
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
local env_app_cpus env_app_mem env_app_mem_swap env_workers
env_app_cpus="$(awk -F= '/^INVENTAR_APP_CPUS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem="$(awk -F= '/^INVENTAR_APP_MEM_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem_swap="$(awk -F= '/^INVENTAR_APP_MEM_SWAP_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_workers="$(awk -F= '/^INVENTAR_WORKERS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
[ -n "$env_app_cpus" ] && app_cpus="$env_app_cpus"
[ -n "$env_app_mem" ] && app_mem="$env_app_mem"
[ -n "$env_app_mem_swap" ] && app_mem_swap="$env_app_mem_swap"
[ -n "$env_workers" ] && app_workers="$env_workers"
fi
if [ -z "$requested_https" ]; then
requested_https="443"
fi
INVENTAR_APP_CPUS="${INVENTAR_APP_CPUS:-$app_cpus}"
INVENTAR_APP_MEM_LIMIT="${INVENTAR_APP_MEM_LIMIT:-$app_mem}"
INVENTAR_APP_MEM_SWAP_LIMIT="${INVENTAR_APP_MEM_SWAP_LIMIT:-$app_mem_swap}"
INVENTAR_WORKERS="${INVENTAR_WORKERS:-$app_workers}"
INVENTAR_THREADS="${INVENTAR_THREADS:-2}"
if stack_owns_host_port "$requested_https" "443"; then
HTTPS_PORT_VALUE="$requested_https"
elif port_in_use "$requested_https"; then
stop_host_nginx_services || true
if ! port_in_use "$requested_https"; then
HTTPS_PORT_VALUE="$requested_https"
echo "Freed HTTPS port $requested_https by stopping host nginx service"
return
fi
HTTPS_PORT_VALUE="$(find_free_port 8443)"
echo "HTTPS port 443 is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else
HTTPS_PORT_VALUE="$requested_https"
fi
echo "Detected host capacity: CPUs=$cpus, RAM=${mem_gb}GB"
echo "Configured app runtime: INVENTAR_WORKERS=$INVENTAR_WORKERS, INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS, INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT"
}
write_env_file() {
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTPS_PORT=$HTTPS_PORT_VALUE
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS
INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT
INVENTAR_APP_MEM_SWAP_LIMIT=$INVENTAR_APP_MEM_SWAP_LIMIT
INVENTAR_WORKERS=$INVENTAR_WORKERS
INVENTAR_THREADS=$INVENTAR_THREADS
INVENTAR_WORKER_TIMEOUT=${INVENTAR_WORKER_TIMEOUT:-30}
INVENTAR_WORKER_CONNECTIONS=${INVENTAR_WORKER_CONNECTIONS:-100}
EOF
}
write_runtime_compose_override() {
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ${APP_IMAGE_VALUE}
build: null
EOF
if [ -n "$HTTP_PORTS_VALUE" ]; then
local ports_array
read -r -a ports_array <<<"$HTTP_PORTS_VALUE"
if [ "${#ports_array[@]}" -gt 1 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports_array[@]}"; do
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
done
fi
fi
}
verify_stack_health() {
local compose_args running_services retry_count=0
compose_args=(--env-file "$ENV_FILE")
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_args+=(--env-file "$ENV_FILE")
# Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do
@@ -558,10 +612,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \
printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then
if curl -kfsS "https://127.0.0.1:$HTTPS_PORT_VALUE" >/dev/null 2>&1; then
if curl -fsS "http://127.0.0.1:$HTTP_PORT_VALUE/health" >/dev/null 2>&1; then
echo "Health check passed."
return 0
fi
@@ -574,8 +628,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" restart app nginx mongodb
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3
((retry_count++))
else
@@ -586,7 +640,7 @@ verify_stack_health() {
# Final failure
echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
return 1
}
@@ -594,20 +648,33 @@ parse_args "$@"
ensure_runtime_dependencies
setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json
setup_scheduled_jobs
configure_nuitka_mode
resolve_app_image
configure_host_ports
ensure_min_docker_disk_space
detect_server_capacity
ensure_app_image_loaded
write_env_file
write_runtime_compose_override
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
compose_up_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_up_args+=(--env-file "$ENV_FILE")
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
fi
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
sleep 5
docker compose "${compose_up_args[@]}" up -d --remove-orphans
fi
verify_stack_health
echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE"
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+19 -1
View File
@@ -4,12 +4,30 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Stopping Inventarsystem Docker stack..."
docker compose down
docker compose -f "$COMPOSE_FILE" down
echo "Stack stopped."
+141 -84
View File
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist"
COMPOSE_FILE="docker-compose-multitenant.yml"
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true
@@ -35,6 +38,11 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() {
$SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -44,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io)
missing+=(docker)
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -64,88 +72,113 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
log_message "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
log_message "ERROR: Missing dependencies: ${missing[*]}"
log_message "Install the missing tools manually or re-run as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
fi
}
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$PROJECT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
ensure_min_root_disk_space() {
local available_kb available_mb
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
if ! command -v df >/dev/null 2>&1; then
return 0
fi
cn="${TLS_CN:-localhost}"
log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$PROJECT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
log_message "WARNING: Moved unexpected directory $config_path to $backup_path"
available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
if [ -z "$available_kb" ]; then
return 0
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
available_mb=$((available_kb / 1024))
if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
log_message "Free disk space and rerun update."
exit 1
fi
}
server {
listen 443 ssl;
server_name _;
cleanup_old_dist_artifacts() {
local keep_count
keep_count="$DIST_KEEP_COUNT"
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
if [ ! -d "$DIST_DIR" ]; then
return 0
fi
client_max_body_size 50M;
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
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
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
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() {
if docker image prune -f >> "$LOG_FILE" 2>&1; then
log_message "Cleaned up dangling Docker images"
else
log_message "WARNING: Could not prune dangling Docker images"
fi
}
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
-h, --help Show this help message
EOF
log_message "Recreated missing nginx config at $config_path"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help)
usage
exit 0
;;
*)
log_message "ERROR: Unknown option: $1"
usage
exit 2
;;
esac
done
}
archive_logs() {
@@ -311,9 +344,7 @@ download_and_extract_bundle() {
tar -xzf "$archive" -C "$tmp_dir"
# The bundle must contain docker deployment files only.
mkdir -p "$PROJECT_DIR/docker/nginx"
cp -f "$tmp_dir/docker-compose.yml" "$PROJECT_DIR/docker-compose.yml"
cp -f "$tmp_dir/docker/nginx/default.conf" "$PROJECT_DIR/docker/nginx/default.conf"
cp -f "$tmp_dir/start.sh" "$PROJECT_DIR/start.sh"
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.sh"
@@ -329,9 +360,6 @@ download_and_extract_bundle() {
if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi
if [ -f "$tmp_dir/docker/nginx/multitenant.conf" ]; then
cp -f "$tmp_dir/docker/nginx/multitenant.conf" "$PROJECT_DIR/docker/nginx/multitenant.conf"
fi
if [ -f "$tmp_dir/manage-tenant.sh" ]; then
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
fi
@@ -360,13 +388,19 @@ deploy() {
local tag="$1"
local meta_file="$2"
local app_image="${APP_IMAGE_REPO}:${tag}"
local compose_path
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
log_message "ERROR: compose file not found: $compose_path"
exit 1
fi
cd "$PROJECT_DIR"
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_APP_IMAGE=$app_image
EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
@@ -385,27 +419,27 @@ EOF
fi
fi
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$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
}
verify_stack_health() {
local compose_args running_services
local https_port
compose_args=(--env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then
https_port="443"
local http_port
compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$http_port" ]; then
http_port="10000"
fi
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \
printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
# Primary check: HTTP endpoint responds (most reliable)
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then
# Primary check: health endpoint responds (most reliable)
if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
return 0
fi
fi
@@ -413,20 +447,41 @@ verify_stack_health() {
done
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb >> "$LOG_FILE" 2>&1 || true
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb >> "$LOG_FILE" 2>&1 || true
return 1
}
cleanup_server_space() {
log_message "Running server cleanup before update..."
# Remove unused Docker objects
if docker system prune -af --volumes >> "$LOG_FILE" 2>&1; then
log_message "Docker system pruned (all unused images, containers, volumes, networks)"
else
log_message "WARNING: Docker system prune failed"
fi
# Clean up old dist artifacts
cleanup_old_dist_artifacts
# Clean up log files older than 7 days
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"
else
log_message "WARNING: Failed to clean up old log files"
fi
}
main() {
parse_args "$@"
cleanup_server_space
ensure_runtime_dependencies
ensure_tls_certificates
ensure_nginx_config_mount_source
require_cmd curl
require_cmd tar
require_cmd docker
require_cmd python3
ensure_min_root_disk_space
archive_logs
create_backup
@@ -514,6 +569,8 @@ main() {
fi
echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag"
}