Compare commits

...

35 Commits

Author SHA1 Message Date
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
24 changed files with 1140 additions and 519 deletions
+1 -2
View File
@@ -1,4 +1,3 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest 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 - name: Create release-only docker bundle
run: | run: |
mkdir -p release-bundle mkdir -p release-bundle
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF cat > release-bundle/docker-compose.yml <<EOF
services: 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: app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest} image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
pull_policy: never
container_name: inventarsystem-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
depends_on: depends_on:
mongodb: - mongodb
condition: service_healthy - redis
environment: environment:
INVENTAR_MONGODB_HOST: mongodb INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017" INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +186,33 @@ jobs:
- app_backups:/data/backups - app_backups:/data/backups
- app_logs:/data/logs - 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: volumes:
mongodb_data: mongodb_data:
app_uploads: app_uploads:
@@ -219,9 +221,9 @@ jobs:
app_qrcodes: app_qrcodes:
app_backups: app_backups:
app_logs: app_logs:
redis_data:
EOF EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh cp restart.sh release-bundle/restart.sh
@@ -232,7 +234,6 @@ jobs:
# Multitenant scripts & docs # Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml 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 manage-tenant.sh release-bundle/manage-tenant.sh
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md 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 │ │ schule1 │ │ schule2 │ │ schule3 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │ │ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │ │ 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 ## Support & Debugging
**Fragen?** **Fragen?**
+38 -4
View File
@@ -339,6 +339,28 @@ def _enforce_user_permissions():
return None 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(): def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state.""" """Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip() 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 if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path) os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)") app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception: except Exception as e:
pass # If we can't clean up, continue anyway 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:
# Try to create the lock file - only succeeds if it doesn't exist # 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) 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: except FileExistsError:
should_start = False should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler") app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start: if should_start:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS) 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) scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
@@ -10784,6 +10814,10 @@ def get_optimal_image_quality(img, target_size_kb=80):
# PUSH NOTIFICATION API ENDPOINTS # PUSH NOTIFICATION API ENDPOINTS
# ============================================================================ # ============================================================================
@app.route('/health')
def health_check():
return 'OK', 200
@app.route('/api/push/subscribe', methods=['POST']) @app.route('/api/push/subscribe', methods=['POST'])
def subscribe_to_push(): def subscribe_to_push():
""" """
+2 -2
View File
@@ -13,6 +13,6 @@ redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
cryptography cryptography>=42.0.0
pywebpush pywebpush
py-vapid==1.9.0 py-vapid>=1.9.0
+30 -2
View File
@@ -157,8 +157,36 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods']) SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
# Optional feature modules # Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled'])) TENANT_CONFIGS = _get(_conf, ['tenants'], {})
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
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_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'])) STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
+148 -62
View File
@@ -306,6 +306,7 @@
align-items: center; align-items: center;
margin-right: 10px; margin-right: 10px;
width: min(420px, 42vw); width: min(420px, 42vw);
min-width: 0;
} }
.function-search-form { .function-search-form {
@@ -313,10 +314,13 @@
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
min-width: 0;
} }
.function-search-input { .function-search-input {
width: 100%; flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
@@ -337,6 +341,7 @@
} }
.function-search-btn { .function-search-btn {
flex: 0 0 auto;
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45); border: 1px solid rgba(255, 255, 255, 0.45);
@@ -539,9 +544,48 @@
margin-top: 4px; margin-top: 4px;
} }
.navbar-collapse {
width: 100%;
}
.navbar-collapse > .d-flex {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.function-search-wrap { .function-search-wrap {
width: 100%; width: 100%;
max-width: 100%;
margin: 8px 0 10px; 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 { .navbar-nav .nav-item {
@@ -911,12 +955,24 @@
<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') }}">Artikel</a>
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<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') }}">Meine Ausleihen</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<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') }}">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) %} {% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item"> <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') }}"> Hochladen</a>
</li> </li>
{% endif %} {% 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"> <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-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
@@ -928,15 +984,6 @@
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown"> <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) %} {% 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> <li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %} {% if current_permissions.pages.get('manage_filters', True) %}
@@ -1027,12 +1074,24 @@
<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') }}">Medien</a>
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<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') }}">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<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') }}">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) %} {% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item"> <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') }}">📖 Hochladen</a>
</li> </li>
{% endif %} {% 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"> <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-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span> <span class="theme-icon-dark" style="display: none;">🌙</span>
@@ -1044,15 +1103,6 @@
Mehr Optionen Mehr Optionen
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown"> <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) %} {% 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> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %} {% if current_permissions.pages.get('library_loans_admin', True) %}
@@ -1630,8 +1680,15 @@
const libraryNavList = document.getElementById('libraryNavList'); const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor'); const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) { function initNavbarOverflow(navList, navOverflowAnchor, options) {
if (!navList || !navOverflowAnchor) return; 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 navRoot = navList.closest('nav.navbar');
const navCollapse = navList.closest('.navbar-collapse'); const navCollapse = navList.closest('.navbar-collapse');
@@ -1659,45 +1716,35 @@
return Array.from(navList.children).filter(function(li){ return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false; if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) 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.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false; if (li.dataset.navFixed === 'true') return false;
if (moreControlItem && li === moreControlItem) return false;
return li.style.display !== 'none'; return li.style.display !== 'none';
}); });
} }
function clearOverflowControls() { function clearOverflowControls() {
if (!navList) return; if (!moreMenu) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){ moreMenu.innerHTML = moreMenuOriginal;
node.remove(); if (moreControlItem) {
}); moreControlItem.style.display = '';
}
if (moreToggle) {
moreToggle.textContent = moreLabelDefault;
}
} }
function restoreAllNavItems() { function restoreAllNavItems() {
if (!navList) return; if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){ 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; if (li.dataset.overflowControl === 'true') return;
li.style.display = ''; li.style.display = '';
}); });
clearOverflowControls(); 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) { function getNavCandidatePriority(item) {
if (!(item instanceof HTMLElement)) { if (!(item instanceof HTMLElement)) {
return -1; return -1;
@@ -1780,33 +1827,70 @@
function rebuildOverflowControl(hiddenSources) { function rebuildOverflowControl(hiddenSources) {
clearOverflowControls(); clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return; if (!moreMenu || 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 + ')';
}
const overflowFragment = document.createDocumentFragment();
hiddenSources.forEach(function(source){ hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu); appendSourceToOverflowMenu(source, overflowFragment);
}); });
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider'); const overflowWrap = document.createElement('div');
if (trailingDivider) { overflowWrap.appendChild(overflowFragment);
const trailingDivider = overflowWrap.querySelector('li:last-child .dropdown-divider');
if (trailingDivider && trailingDivider.parentElement) {
trailingDivider.parentElement.remove(); 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() { 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(); 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(); restoreAllNavItems();
return; return;
} }
@@ -1815,8 +1899,9 @@
const hiddenSources = []; const hiddenSources = [];
let candidates = collectTopLevelNavSources(); let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates); const toHide = pickNextNavItemToHide(candidates);
if (!toHide) { if (!toHide) {
break; break;
@@ -1828,8 +1913,9 @@
rebuildOverflowControl(hiddenSources); rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources(); candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) { while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates); const toHide = pickNextNavItemToHide(candidates);
if (!toHide) { if (!toHide) {
break; break;
@@ -1867,12 +1953,12 @@
// Initialize overflow control for inventory navbar // Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) { if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor); initNavbarOverflow(navList, navOverflowAnchor, { moreToggleId: 'invMoreDropdown' });
} }
// Initialize overflow control for library navbar // Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) { if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor); initNavbarOverflow(libraryNavList, libNavOverflowAnchor, { moreToggleId: 'libMoreDropdown' });
} }
})(); })();
</script> </script>
+237 -1
View File
@@ -208,12 +208,23 @@
<script> <script>
// View mode persistence // View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode'; 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) { function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode); localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer'); const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader'); 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 => { items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode'); const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +247,229 @@ function setViewMode(mode) {
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table'); 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 // Initialize view mode
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card'; 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'; if (e.target === this) this.style.display = 'none';
}); });
}); });
initMobileWindowedLibraryLoading();
}); });
function displayLibraryItemDetail(item) { function displayLibraryItemDetail(item) {
+93 -4
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 flask import request, g, has_request_context
from functools import wraps from functools import wraps
import logging import logging
import os
import re
import settings as cfg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tenant registry: maps subdomain/tenant_id to database name # Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {} 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: class TenantContext:
""" """
Manages current tenant context for request lifecycle. 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): def __init__(self):
self.tenant_id = None self.tenant_id = None
self.db_name = None self.db_name = None
self.subdomain = None self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self): def resolve_tenant(self):
""" """
Resolve tenant from request context. Resolve tenant from request context.
Priority: Header > Subdomain > Default Priority: Header > Port mapping > Subdomain > Default
""" """
if not has_request_context(): if not has_request_context():
return None return None
@@ -40,10 +116,21 @@ class TenantContext:
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip() tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header: if tenant_from_header:
self.tenant_id = 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) return self._get_db_name(tenant_from_header)
# Priority 2: Subdomain extraction # Priority 2: Port-based tenant mapping
host = request.host.lower() 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('.') parts = host.split('.')
# Extract subdomain from host # Extract subdomain from host
@@ -56,10 +143,12 @@ class TenantContext:
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'): if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain self.tenant_id = potential_subdomain
self.config = get_tenant_config(potential_subdomain)
return self._get_db_name(potential_subdomain) return self._get_db_name(potential_subdomain)
# Fallback to default tenant if no subdomain detected # Fallback to default tenant if no tenant identifier found
self.tenant_id = 'default' self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default') return self._get_db_name('default')
def _get_db_name(self, tenant_id): def _get_db_name(self, tenant_id):
+10 -3
View File
@@ -33,11 +33,12 @@ LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}" COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}" KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}" MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}" DB_NAME="${DB_NAME:-inventar_default}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}" MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}" INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}" INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}" BACKUP_MODE="${BACKUP_MODE:-auto}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose-multitenant.yml}"
DOCKER_AVAILABLE=0 DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=() DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false USE_NULL_OUTPUT=false
@@ -58,6 +59,8 @@ Options:
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter) --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) --min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE) --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 -h|--help Show this help and exit
EOF EOF
} }
@@ -87,6 +90,10 @@ while [[ $# -gt 0 ]]; do
MIN_KEEP="$2"; shift 2;; MIN_KEEP="$2"; shift 2;;
--mode) --mode)
BACKUP_MODE="$2"; shift 2;; BACKUP_MODE="$2"; shift 2;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"; shift;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"; shift;;
-h|--help) -h|--help)
usage; exit 0;; usage; exit 0;;
*) *)
@@ -102,12 +109,12 @@ log_message() {
init_docker_compose() { init_docker_compose() {
if docker compose version >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1 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 return 0
fi fi
if sudo docker compose version >/dev/null 2>&1; then if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1 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 return 0
fi fi
+4 -2
View File
@@ -1,9 +1,9 @@
{ {
"dbg": false, "dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012", "key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2", "ver": "0.6.44",
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 443, "port": 8000,
"mongodb": { "mongodb": {
"host": "localhost", "host": "localhost",
@@ -50,6 +50,8 @@
} }
}, },
"tenants": {},
"allowed_extensions": [ "allowed_extensions": [
"png", "jpg", "jpeg", "gif", "png", "jpg", "jpeg", "gif",
"hevc", "heif", "hevc", "heif",
+7 -32
View File
@@ -1,12 +1,12 @@
# Multi-Tenant Optimized Docker Compose # 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 # Each instance: ~50MB base + 20-30MB per 20 users
# #
# Usage: # Usage:
# docker-compose -f docker-compose-multitenant.yml up -d # docker-compose -f docker-compose-multitenant.yml up -d
# #
# Scale example: To support 10 tenants with 20 users each: # Example: Start the stack and expose the app on host port 10000
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10 # INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
services: services:
# Management Container for multi-tenant scripts # Management Container for multi-tenant scripts
@@ -21,33 +21,6 @@ services:
- .:/workspace - .:/workspace
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"] entrypoint: ["sh", "-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
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: inventarsystem-redis container_name: inventarsystem-redis
@@ -69,7 +42,6 @@ services:
image: mongo:7.0 image: mongo:7.0
container_name: inventarsystem-mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2
expose: expose:
- "27017" - "27017"
volumes: volumes:
@@ -92,6 +64,8 @@ services:
args: args:
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
OPTIMIZATION_LEVEL: 2 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 restart: unless-stopped
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
@@ -100,13 +74,14 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks: networks:
- inventar-net - inventar-net
expose: expose:
- "8000" - "8000"
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached - app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached - app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached - app_previews:/app/Web/previews:cached
+26 -72
View File
@@ -1,87 +1,41 @@
version: "3.8"
services: services:
nginx: app:
image: nginx:1.27-alpine build: .
container_name: inventarsystem-nginx container_name: inventory-app
restart: unless-stopped 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: depends_on:
app: - mongodb
condition: service_started - redis
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: mongodb:
image: mongo:7.0 image: mongo:latest
container_name: inventarsystem-mongodb container_name: mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongodb_data:/data/db - mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:7-alpine image: redis:alpine
container_name: inventarsystem-redis container_name: redis
restart: unless-stopped 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: cloudflared:
build: image: cloudflare/cloudflared:latest
context: . container_name: cloudflared
dockerfile: Dockerfile
container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
security_opt: # Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
- no-new-privileges:true command: tunnel run homeserver
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"
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.yml:/etc/cloudflared/config.yml
- ./Web:/app/Web:ro - ./credentials.json:/etc/cloudflared/credentials.json
- app_uploads:/app/Web/uploads depends_on:
- app_thumbnails:/app/Web/thumbnails - app
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
volumes: volumes:
mongodb_data: mongo_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_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
+29 -10
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR="" TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() { cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
pin_compose_app_image() { pin_compose_app_image() {
local tag="$1" local tag="$1"
local compose_file local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
if [ ! -f "$compose_file" ]; then for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
echo "Warning: $compose_file not found; cannot pin app image" if [ ! -f "$compose_file" ]; then
return 0 continue
fi fi
python3 - <<'PY' "$compose_file" "$tag" python3 - <<'PY' "$compose_file" "$tag"
import re import re
import sys 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: with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out) f.writelines(out)
PY PY
done
} }
install_docker_if_missing() { install_docker_if_missing() {
@@ -173,6 +175,8 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import --remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install --skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup --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-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI) --legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration --legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +203,14 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true CLEANUP_OLD_REMOVE_CRON=true
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--legacy-db-name) --legacy-db-name)
LEGACY_DB_NAME="$2" LEGACY_DB_NAME="$2"
shift 2 shift 2
@@ -280,14 +292,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() { restore_legacy_backup_into_docker() {
local i local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0 return 0
fi 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..." echo "Waiting for Docker MongoDB to become ready..."
for i in $(seq 1 60); do 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 break
fi fi
sleep 2 sleep 2
@@ -299,7 +318,7 @@ restore_legacy_backup_into_docker() {
fi fi
echo "Importing legacy backup into Docker MongoDB..." 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." echo "Legacy DB import completed."
} }
@@ -454,8 +473,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443 INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env" sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
+63 -12
View File
@@ -1,4 +1,6 @@
#!/bin/sh #!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Script to manage multitenant deployment # Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others # Allows adding, removing, and restarting tenants without downtime for others
@@ -7,23 +9,64 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1 exit 1
fi fi
CONFIG_FILE="$PWD/config.json"
show_help() { show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]" echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " add <tenant_id> Add a new tenant (initializes database)" echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)" echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)" echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)" echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants" echo " list List active tenants"
echo " -h, --help Show this help message"
echo "" echo ""
echo "Examples:" 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 remove test_tenant"
echo " ./manage-tenant.sh restart-all" echo " ./manage-tenant.sh restart-all"
echo " ./manage-tenant.sh -h"
exit 1 exit 1
} }
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
}
if [ -z "$1" ]; then if [ -z "$1" ]; then
show_help show_help
fi fi
@@ -32,17 +75,25 @@ COMMAND=$1
TENANT_ID=$2 TENANT_ID=$2
case "$COMMAND" in case "$COMMAND" in
-h|--help)
show_help
;;
add) add)
if [ -z "$TENANT_ID" ]; then if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id." echo "Error: Please provide a tenant_id."
exit 1 exit 1
fi fi
echo "Adding new tenant '$TENANT_ID'..."
# Add Nginx configuration PORT_ARG="$3"
if [ -f "docker/nginx/multitenant.conf" ]; then if [ -n "$PORT_ARG" ]; then
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..." 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"
fi fi
echo "Adding new tenant '$TENANT_ID'..."
# Initialize tenant database via Python inside container # Initialize tenant database via Python inside container
echo "Initializing database for $TENANT_ID..." echo "Initializing database for $TENANT_ID..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1) APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
+2 -2
View File
@@ -12,6 +12,6 @@ redis
reportlab reportlab
python-barcode python-barcode
openpyxl openpyxl
cryptography cryptography>=42.0.0
pywebpush pywebpush
py-vapid==1.9.0 py-vapid>=1.9.0
+51 -5
View File
@@ -1,8 +1,54 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail 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" "$@" COMPOSE_FILE="docker-compose-multitenant.yml"
"$SCRIPT_DIR/start.sh" "$@" ENV_FILE="$SCRIPT_DIR/.docker-build.env"
"$SCRIPT_DIR/start.sh" RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.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 "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
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" LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)" WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}" DB_NAME="${INVENTAR_MONGODB_DB:-inventar_default}"
SOURCE_PATH="" SOURCE_PATH=""
BACKUP_DATE="" BACKUP_DATE=""
DROP_DATABASE=false DROP_DATABASE=false
RESTART_SERVICES=false RESTART_SERVICES=false
STAGED_PATH="" STAGED_PATH=""
COMPOSE_FILE="docker-compose-multitenant.yml"
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups" BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups" BACKUP_ROOT_SYSTEM="/var/backups"
@@ -56,6 +57,8 @@ Options:
- latest: newest from local/system backup roots - latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore) --drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore --restart-services Restart stack after restore
--multitenant Use docker-compose-multitenant.yml (default)
--singletenant Use docker-compose.yml
--list List detected backup candidates --list List detected backup candidates
--help Show this help --help Show this help
@@ -68,12 +71,12 @@ EOF
setup_compose() { setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then 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 return 0
fi fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then 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 return 0
fi fi
@@ -459,6 +462,14 @@ parse_args() {
RESTART_SERVICES=true RESTART_SERVICES=true
shift shift
;; ;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
--list) --list)
list_backups list_backups
exit 0 exit 0
+165 -183
View File
@@ -7,18 +7,27 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env" ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist" DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO="" SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo" SUDO="sudo"
fi fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0" NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="8001" HTTP_PORT_VALUE="10000"
HTTPS_PORT_VALUE="8443" HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}" CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}" APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
COMPOSE_PROFILES_VALUE=""
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
usage() { usage() {
cat <<EOF cat <<EOF
@@ -28,6 +37,7 @@ Options:
--no-cron Do not create or update cron jobs --no-cron Do not create or update cron jobs
--with-cron Create/update cron jobs (default) --with-cron Create/update cron jobs (default)
--multitenant Use the multi-tenant architecture deployment --multitenant Use the multi-tenant architecture deployment
--singletenant Use the legacy single-tenant compose deployment
-h, --help Show this help message -h, --help Show this help message
EOF EOF
} }
@@ -47,6 +57,10 @@ parse_args() {
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -106,7 +120,11 @@ ensure_runtime_dependencies() {
local missing=() local missing=()
if ! command -v docker >/dev/null 2>&1; then 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 fi
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
@@ -130,14 +148,20 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}" if [ "$IS_ROOT" = "true" ]; then
apt_install "${missing[@]}" 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 fi
if command -v systemctl >/dev/null 2>&1; then if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then 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
fi fi
} }
@@ -150,6 +174,11 @@ setup_boot_autostart_service() {
return 0 return 0
fi 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 if ! command -v systemctl >/dev/null 2>&1; then
return 0 return 0
fi fi
@@ -191,6 +220,11 @@ setup_scheduled_jobs() {
return 0 return 0
fi 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 if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup" echo "Warning: crontab not available, skipping nightly update setup"
return 0 return 0
@@ -201,101 +235,17 @@ setup_scheduled_jobs() {
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1" backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron local existing_cron
if [ "$(id -u)" -eq 0 ]; then existing_cron="$(crontab -l 2>/dev/null || true)"
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
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 "$backup_line" echo "$update_line"
echo "$update_line" } | crontab -
} | 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
echo "Nightly backup scheduled at 02:30" echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00" 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() { ensure_runtime_config_json() {
local config_path backup_path local config_path backup_path
config_path="$SCRIPT_DIR/config.json" config_path="$SCRIPT_DIR/config.json"
@@ -312,7 +262,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5", "ver": "2.6.5",
"dbg": false, "dbg": false,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 443, "port": 8000,
"mongodb": { "mongodb": {
"host": "mongodb", "host": "mongodb",
"port": 27017, "port": 27017,
@@ -451,97 +401,87 @@ find_free_port() {
echo "$port" echo "$port"
} }
stack_owns_host_port() { parse_port_list() {
local requested_port="$1" local raw="$1"
local container_port="$2" local port
local mapped_port local ports=()
raw="${raw//,/ }"
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)" for port in $raw; do
if [ -z "$mapped_port" ]; then port="${port//[[:space:]]/}"
return 1 if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
fi ports+=("$port")
fi
mapped_port="${mapped_port##*:}" done
[ "$mapped_port" = "$requested_port" ] printf '%s\n' "${ports[@]}"
}
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
} }
configure_host_ports() { configure_host_ports() {
local requested_http requested_https local requested_http
local requested_ports
local ports=()
requested_http="" requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
if [ -z "$requested_http" ]; then
requested_http="8001"
fi fi
if stack_owns_host_port "$requested_http" "80"; then if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
HTTP_PORT_VALUE="$requested_http" requested_ports="$INVENTAR_HTTP_PORTS"
elif port_in_use "$requested_http"; then fi
if ! port_in_use "$requested_http"; then if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
HTTP_PORT_VALUE="$requested_http" requested_ports="$INVENTAR_HTTP_PORT"
echo "Freed HTTP port $requested_http by stopping host nginx service" fi
else
HTTP_PORT_VALUE="$(find_free_port 8080)" if [ -n "$requested_ports" ]; then
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE" mapfile -t ports < <(parse_port_list "$requested_ports")
fi fi
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
else else
HTTP_PORT_VALUE="$requested_http" HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi fi
requested_https="" if port_in_use "$HTTP_PORT_VALUE"; then
if [ -f "$ENV_FILE" ]; then HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)" echo "Host port ${ports[0]:-$DEFAULT_TENANT_PORT_START} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
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 fi
if [ -z "$requested_https" ]; then docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
requested_https="8443" if [ -z "$docker_root" ]; then
docker_root="/var/lib/docker"
fi fi
if stack_owns_host_port "$requested_https" "443"; then if [ ! -d "$docker_root" ]; then
HTTPS_PORT_VALUE="$requested_https" return 0
elif port_in_use "$requested_https"; then fi
if ! port_in_use "$requested_https"; then available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
HTTPS_PORT_VALUE="$requested_https" if [ -z "$available_kb" ]; then
echo "Freed HTTPS port $requested_https by stopping host nginx service" return 0
return fi
fi
HTTPS_PORT_VALUE="$(find_free_port 8443)" available_mb=$((available_kb / 1024))
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
HTTPS_PORT_VALUE="$requested_https" 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 fi
} }
@@ -549,14 +489,44 @@ write_env_file() {
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_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_IMAGE=$APP_IMAGE_VALUE
EOF 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", "2", "--timeout", "30", "--graceful-timeout", "20", "--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() { verify_stack_health() {
local compose_args running_services retry_count=0 local compose_args running_services retry_count=0
compose_args=(-f "$COMPOSE_FILE" --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 # Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do while [[ $retry_count -lt 2 ]]; do
@@ -564,10 +534,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \ 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 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 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." echo "Health check passed."
return 0 return 0
fi fi
@@ -580,8 +550,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..." echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
docker compose "${compose_args[@]}" restart app nginx mongodb docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3 sleep 3
((retry_count++)) ((retry_count++))
else else
@@ -592,7 +562,7 @@ verify_stack_health() {
# Final failure # Final failure
echo "Error: stack health check failed after restart attempt." echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
return 1 return 1
} }
@@ -600,20 +570,32 @@ parse_args "$@"
ensure_runtime_dependencies ensure_runtime_dependencies
setup_boot_autostart_service setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json ensure_runtime_config_json
setup_scheduled_jobs setup_scheduled_jobs
configure_nuitka_mode configure_nuitka_mode
resolve_app_image resolve_app_image
configure_host_ports configure_host_ports
ensure_min_docker_disk_space
ensure_app_image_loaded ensure_app_image_loaded
write_env_file write_env_file
write_runtime_compose_override
echo "Starting Inventarsystem Docker stack (app + mongodb)..." echo "Starting Inventarsystem Docker stack (app + mongodb)..."
docker compose -f "$COMPOSE_FILE" --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 verify_stack_health
echo "Stack started." echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE" echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+5 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -12,6 +12,10 @@ while [[ $# -gt 0 ]]; do
COMPOSE_FILE="docker-compose-multitenant.yml" COMPOSE_FILE="docker-compose-multitenant.yml"
shift shift
;; ;;
--singletenant)
COMPOSE_FILE="docker-compose.yml"
shift
;;
*) *)
shift shift
;; ;;
+141 -84
View File
@@ -15,6 +15,9 @@ APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
ENV_FILE="$PROJECT_DIR/.docker-build.env" ENV_FILE="$PROJECT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo" APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$PROJECT_DIR/dist" 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" mkdir -p "$LOG_DIR"
chmod 777 "$LOG_DIR" 2>/dev/null || true 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" SUDO="sudo"
fi fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
apt_install() { apt_install() {
$SUDO apt-get update -y $SUDO apt-get update -y
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
@@ -44,7 +52,7 @@ ensure_runtime_dependencies() {
local missing=() local missing=()
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
missing+=(docker.io) missing+=(docker)
fi fi
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
@@ -64,88 +72,113 @@ ensure_runtime_dependencies() {
fi fi
if [ "${#missing[@]}" -gt 0 ]; then if [ "${#missing[@]}" -gt 0 ]; then
log_message "Installing missing dependencies: ${missing[*]}" if [ "$IS_ROOT" = "true" ]; then
apt_install "${missing[@]}" 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 fi
if command -v systemctl >/dev/null 2>&1; then if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true systemctl enable --now docker >/dev/null 2>&1 || true
fi fi
} }
ensure_tls_certificates() { ensure_min_root_disk_space() {
local cert_dir cert_path key_path cn local available_kb available_mb
cert_dir="$PROJECT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir" if ! command -v df >/dev/null 2>&1; then
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0 return 0
fi fi
cn="${TLS_CN:-localhost}" available_kb="$(df -Pk "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
log_message "No TLS certificates found. Generating self-signed certificate for CN=$cn" if [ -z "$available_kb" ]; then
return 0
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"
fi fi
if [ ! -f "$config_path" ]; then available_mb=$((available_kb / 1024))
cat > "$config_path" <<'EOF' if [ "$available_mb" -lt "$MIN_ROOT_FREE_MB" ]; then
server { log_message "ERROR: Low disk space on filesystem containing $PROJECT_DIR"
listen 80; log_message "Available: ${available_mb} MB; required minimum: ${MIN_ROOT_FREE_MB} MB"
server_name _; log_message "Free disk space and rerun update."
return 301 https://$host$request_uri; exit 1
fi
} }
server { cleanup_old_dist_artifacts() {
listen 443 ssl; local keep_count
server_name _; keep_count="$DIST_KEEP_COUNT"
ssl_certificate /etc/nginx/certs/inventarsystem.crt; if [ ! -d "$DIST_DIR" ]; then
ssl_certificate_key /etc/nginx/certs/inventarsystem.key; return 0
fi
client_max_body_size 50M; if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
keep_count=2
fi
location / { 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}')
proxy_pass http://app:8000; if [ "${#archives[@]}" -le "$keep_count" ]; then
proxy_http_version 1.1; return 0
proxy_set_header Host $host; fi
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; local index old_archive deleted=0
location = /50x.html { for (( index=keep_count; index<${#archives[@]}; index++ )); do
default_type text/html; old_archive="${archives[$index]}"
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>'; 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 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() { archive_logs() {
@@ -311,9 +344,7 @@ download_and_extract_bundle() {
tar -xzf "$archive" -C "$tmp_dir" tar -xzf "$archive" -C "$tmp_dir"
# The bundle must contain docker deployment files only. # 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-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/start.sh" "$PROJECT_DIR/start.sh"
cp -f "$tmp_dir/stop.sh" "$PROJECT_DIR/stop.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 if [ -f "$tmp_dir/docker-compose-multitenant.yml" ]; then
cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml" cp -f "$tmp_dir/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose-multitenant.yml"
fi 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 if [ -f "$tmp_dir/manage-tenant.sh" ]; then
cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh" cp -f "$tmp_dir/manage-tenant.sh" "$PROJECT_DIR/manage-tenant.sh"
fi fi
@@ -360,13 +388,19 @@ deploy() {
local tag="$1" local tag="$1"
local meta_file="$2" local meta_file="$2"
local app_image="${APP_IMAGE_REPO}:${tag}" 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" cd "$PROJECT_DIR"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=$app_image INVENTAR_APP_IMAGE=$app_image
EOF EOF
elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then elif grep -q '^INVENTAR_APP_IMAGE=' "$ENV_FILE"; then
@@ -385,27 +419,27 @@ EOF
fi fi
fi fi
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1 docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app 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" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
} }
verify_stack_health() { verify_stack_health() {
local compose_args running_services local compose_args running_services
local https_port local http_port
compose_args=(--env-file "$ENV_FILE") compose_args=(-f "$PROJECT_DIR/$COMPOSE_FILE" --env-file "$ENV_FILE")
https_port="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')" http_port="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ')"
if [ -z "$https_port" ]; then if [ -z "$http_port" ]; then
https_port="443" http_port="10000"
fi fi
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)" running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \ 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 printf '%s\n' "$running_services" | grep -Fxq mongodb; then
# Primary check: HTTP endpoint responds (most reliable) # Primary check: health endpoint responds (most reliable)
if curl -kfsS "https://127.0.0.1:$https_port" >/dev/null 2>&1; then if curl -fsS "http://127.0.0.1:$http_port/health" >/dev/null 2>&1; then
return 0 return 0
fi fi
fi fi
@@ -413,20 +447,41 @@ verify_stack_health() {
done done
docker compose "${compose_args[@]}" ps >> "$LOG_FILE" 2>&1 || true 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 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() { main() {
parse_args "$@"
cleanup_server_space
ensure_runtime_dependencies ensure_runtime_dependencies
ensure_tls_certificates
ensure_nginx_config_mount_source
require_cmd curl require_cmd curl
require_cmd tar require_cmd tar
require_cmd docker require_cmd docker
require_cmd python3 require_cmd python3
ensure_min_root_disk_space
archive_logs archive_logs
create_backup create_backup
@@ -514,6 +569,8 @@ main() {
fi fi
echo "$latest_tag" > "$STATE_FILE" echo "$latest_tag" > "$STATE_FILE"
cleanup_old_dist_artifacts
cleanup_docker_dangling_images
log_message "Update completed successfully to release $latest_tag" log_message "Update completed successfully to release $latest_tag"
} }