From 15c217cc0a726e22b48fcdd38ac8085543a2e3a2 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 29 Dec 2025 07:34:20 +0100 Subject: [PATCH 01/88] added new db downloader --- docker/db-downloader/Dockerfile | 25 ++ docker/db-downloader/app.py | 444 +++++++++++++++++++++ docker/db-downloader/requirements.txt | 5 + docker/db-downloader/static/app.js | 457 ++++++++++++++++++++++ docker/db-downloader/templates/index.html | 189 +++++++++ 5 files changed, 1120 insertions(+) create mode 100644 docker/db-downloader/Dockerfile create mode 100644 docker/db-downloader/app.py create mode 100644 docker/db-downloader/requirements.txt create mode 100644 docker/db-downloader/static/app.js create mode 100644 docker/db-downloader/templates/index.html diff --git a/docker/db-downloader/Dockerfile b/docker/db-downloader/Dockerfile new file mode 100644 index 000000000..760d57da8 --- /dev/null +++ b/docker/db-downloader/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim-bookworm + +RUN apt-get update && apt-get install -y \ + mariadb-client \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates/ ./templates/ +COPY static/ ./static/ + +RUN mkdir -p /app/downloads /app/ssh-keys + +ENV FLASK_APP=app:app +ENV FLASK_ENV=production +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8082 + +CMD ["gunicorn", "--bind", "0.0.0.0:8082", "--workers", "2", "--threads", "4", "app:app"] diff --git a/docker/db-downloader/app.py b/docker/db-downloader/app.py new file mode 100644 index 000000000..d9988ee74 --- /dev/null +++ b/docker/db-downloader/app.py @@ -0,0 +1,444 @@ +import os +import stat +import uuid +import gzip +import struct +import subprocess +import threading +import time +from datetime import datetime +from flask import Flask, render_template, request, jsonify, session +import paramiko + + +# ============================================================================= +# Configuration +# ============================================================================= +class Config: + SCP_HOST = os.getenv('SCP_HOST', 'localhost') + SCP_PORT = int(os.getenv('SCP_PORT', 22)) + SCP_USERNAME = os.getenv('SCP_USERNAME', 'root') + SCP_DEFAULT_PATH = os.getenv('SCP_DEFAULT_PATH', '/backups') + + DB_HOST = os.getenv('DB_HOST', 'db') + DB_PORT = int(os.getenv('DB_PORT', 3306)) + DB_USER = os.getenv('DB_USER', 'root') + DB_PASSWORD = os.getenv('DB_PASSWORD', '') + DB_AVAILABLE = os.getenv('DB_AVAILABLE', 'thetool,addressdb').split(',') + + DOWNLOAD_PATH = '/app/downloads' + SSH_KEYS_PATH = '/app/ssh-keys' + SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24).hex()) + + +# ============================================================================= +# SFTP Client +# ============================================================================= +class SFTPClient: + def __init__(self, host, port, username): + self.host = host + self.port = port + self.username = username + self.client = None + self.sftp = None + + def connect_password(self, password): + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect( + hostname=self.host, port=self.port, username=self.username, + password=password, look_for_keys=False, allow_agent=False + ) + self.sftp = self.client.open_sftp() + + def connect_key(self, key_path, passphrase=None): + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.connect( + hostname=self.host, port=self.port, username=self.username, + key_filename=key_path, passphrase=passphrase, + look_for_keys=False, allow_agent=False + ) + self.sftp = self.client.open_sftp() + + def list_directory(self, path): + entries = [] + for entry in self.sftp.listdir_attr(path): + is_dir = stat.S_ISDIR(entry.st_mode) + entries.append({ + 'name': entry.filename, + 'size': entry.st_size, + 'size_human': self._human_size(entry.st_size), + 'mtime': entry.st_mtime, + 'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'), + 'is_dir': is_dir, + 'is_sql': entry.filename.endswith(('.sql', '.sql.gz')), + 'path': os.path.join(path, entry.filename) + }) + return sorted(entries, key=lambda x: (not x['is_dir'], -x['mtime'])) + + def get_file_info(self, path): + entry = self.sftp.stat(path) + return { + 'name': os.path.basename(path), + 'size': entry.st_size, + 'size_human': self._human_size(entry.st_size), + 'mtime': entry.st_mtime, + 'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'), + 'path': path + } + + def download_file(self, remote_path, local_path, callback=None): + self.sftp.get(remote_path, local_path, callback=callback) + + def close(self): + if self.sftp: + self.sftp.close() + if self.client: + self.client.close() + + @staticmethod + def _human_size(size): + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} PB" + + +# ============================================================================= +# Database Restore +# ============================================================================= +class DatabaseRestore: + def __init__(self): + self.host = Config.DB_HOST + self.port = Config.DB_PORT + self.user = Config.DB_USER + self.password = Config.DB_PASSWORD + self.available_dbs = Config.DB_AVAILABLE + self.cancelled = False + + def cancel(self): + self.cancelled = True + + @staticmethod + def get_gzip_uncompressed_size(filepath): + with open(filepath, 'rb') as f: + f.seek(-4, 2) + return struct.unpack(' 0 else 0, 'downloaded': transferred, 'total': total}) + + client.download_file(remote_file, local_file, callback=download_progress) + client.close() + + if jobs[job_id].get('cancelled'): + jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'}) + if os.path.exists(local_file): + os.remove(local_file) + return + + jobs[job_id].update({'progress': 45, 'message': 'Download complete. Preparing restore...', 'status': 'restoring'}) + jobs[job_id]['progress'] = 50 + jobs[job_id]['message'] = f'Clearing database {target_db}...' + + restorer = DatabaseRestore() + restorers[job_id] = restorer + + uncompressed_size = restorer.get_gzip_uncompressed_size(local_file) if local_file.endswith('.gz') else os.path.getsize(local_file) + + def restore_progress(bytes_processed): + if jobs[job_id].get('cancelled'): + restorer.cancel() + pct = 50 + min(45, int((bytes_processed / uncompressed_size) * 45)) if uncompressed_size > 0 else 50 + jobs[job_id].update({'progress': pct, 'message': f'Restoring to {target_db}... ({bytes_processed // (1024*1024)} MB / {uncompressed_size // (1024*1024)} MB)'}) + + result = restorer.restore_from_file(local_file, target_db, progress_callback=restore_progress) + + if os.path.exists(local_file): + os.remove(local_file) + + jobs[job_id].update({ + 'status': 'completed', 'progress': 100, + 'message': f'Restore complete! Dropped {result["tables_dropped"]} tables and imported {result["file"]}', + 'completed_at': time.time(), 'duration': time.time() - jobs[job_id]['started_at'] + }) + + except Exception as e: + error_msg = str(e) + if 'cancelled' in error_msg.lower(): + jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'}) + else: + jobs[job_id].update({'status': 'error', 'error': error_msg, 'message': f'Error: {error_msg}'}) + if os.path.exists(local_file): + os.remove(local_file) + finally: + restorers.pop(job_id, None) + + +@app.route('/api/status/') +def status(job_id): + if job_id not in jobs: + return jsonify({'success': False, 'error': 'Job not found'}), 404 + job = jobs[job_id].copy() + job['success'] = True + if 'started_at' in job: + elapsed = (job.get('completed_at') or time.time()) - job['started_at'] + job['elapsed'] = f'{int(elapsed // 60)}m {int(elapsed % 60)}s' + return jsonify(job) + + +@app.route('/api/jobs', methods=['GET']) +def list_jobs(): + return jsonify({'success': True, 'jobs': dict(jobs)}) + + +@app.route('/api/cancel/', methods=['POST']) +def cancel(job_id): + if job_id not in jobs: + return jsonify({'success': False, 'error': 'Job not found'}), 404 + if jobs[job_id]['status'] in ('completed', 'error', 'cancelled'): + return jsonify({'success': False, 'error': 'Job already finished'}), 400 + jobs[job_id]['cancelled'] = True + if job_id in restorers: + restorers[job_id].cancel() + return jsonify({'success': True, 'message': 'Cancel signal sent'}) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8082, debug=True) diff --git a/docker/db-downloader/requirements.txt b/docker/db-downloader/requirements.txt new file mode 100644 index 000000000..973a9f474 --- /dev/null +++ b/docker/db-downloader/requirements.txt @@ -0,0 +1,5 @@ +flask==3.0.0 +gunicorn==21.2.0 +paramiko==3.4.0 +mysql-connector-python==8.2.0 +python-dotenv==1.0.0 diff --git a/docker/db-downloader/static/app.js b/docker/db-downloader/static/app.js new file mode 100644 index 000000000..c949e1164 --- /dev/null +++ b/docker/db-downloader/static/app.js @@ -0,0 +1,457 @@ +// DB Restore Tool - Frontend JavaScript + +let currentPath = ''; +let selectedFile = null; +let isConnected = false; +let currentJobId = null; +let pollInterval = null; + +// Initialize +document.addEventListener('DOMContentLoaded', function() { + loadAvailableKeys(); + setupEventListeners(); +}); + +function setupEventListeners() { + // Auth type toggle + document.getElementById('auth-type').addEventListener('change', function() { + const passwordAuth = document.getElementById('password-auth'); + const keyAuth = document.getElementById('key-auth'); + if (this.value === 'password') { + passwordAuth.classList.remove('hidden'); + keyAuth.classList.add('hidden'); + } else { + passwordAuth.classList.add('hidden'); + keyAuth.classList.remove('hidden'); + } + }); + + // Connect form + document.getElementById('connect-form').addEventListener('submit', function(e) { + e.preventDefault(); + connect(); + }); + + // Disconnect button + document.getElementById('disconnect-btn').addEventListener('click', disconnect); + + // Restore button + document.getElementById('restore-btn').addEventListener('click', startRestore); + + // Cancel button + document.getElementById('cancel-btn').addEventListener('click', cancelRestore); +} + +async function loadAvailableKeys() { + try { + const response = await fetch('/api/keys'); + const data = await response.json(); + const select = document.getElementById('key-file'); + select.innerHTML = ''; + data.keys.forEach(key => { + const option = document.createElement('option'); + option.value = key; + option.textContent = key; + select.appendChild(option); + }); + } catch (error) { + console.error('Failed to load keys:', error); + } +} + +async function connect() { + const authType = document.getElementById('auth-type').value; + const password = document.getElementById('password').value; + const keyFile = document.getElementById('key-file').value; + const keyPassphrase = document.getElementById('key-passphrase').value; + + const btn = document.getElementById('connect-btn'); + btn.disabled = true; + btn.textContent = 'Connecting...'; + + try { + const response = await fetch('/api/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_type: authType, + password: password, + key_file: keyFile, + key_passphrase: keyPassphrase + }) + }); + + const data = await response.json(); + + if (data.success) { + isConnected = true; + currentPath = data.path; + showStatus('Connected to ' + data.host, 'success'); + renderFiles(data.files, data.path); + updateBreadcrumb(data.path); + + // Toggle buttons + btn.classList.add('hidden'); + document.getElementById('disconnect-btn').classList.remove('hidden'); + + // Clear password field for security + document.getElementById('password').value = ''; + } else { + showStatus('Connection failed: ' + data.error, 'error'); + } + } catch (error) { + showStatus('Connection error: ' + error.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Connect'; + } +} + +async function disconnect() { + try { + await fetch('/api/disconnect', { method: 'POST' }); + } catch (e) {} + + isConnected = false; + selectedFile = null; + currentPath = ''; + + // Reset UI + document.getElementById('connect-btn').classList.remove('hidden'); + document.getElementById('disconnect-btn').classList.add('hidden'); + document.getElementById('file-browser').innerHTML = ` +
+ + + +

Connect to browse remote files

+
+ `; + document.getElementById('breadcrumb').innerHTML = 'Not connected'; + document.getElementById('selected-file-info').innerHTML = '

No file selected

'; + document.getElementById('restore-btn').disabled = true; + + hideStatus(); +} + +async function browse(path) { + if (!isConnected) return; + + try { + const response = await fetch('/api/browse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path }) + }); + + const data = await response.json(); + + if (data.success) { + currentPath = data.path; + renderFiles(data.files, data.path); + updateBreadcrumb(data.path); + } else { + showStatus('Browse failed: ' + data.error, 'error'); + } + } catch (error) { + showStatus('Browse error: ' + error.message, 'error'); + } +} + +function renderFiles(files, path) { + const container = document.getElementById('file-browser'); + + if (files.length === 0) { + container.innerHTML = '
Empty directory
'; + return; + } + + let html = '
'; + + // Parent directory link + if (path !== '/') { + const parentPath = path.split('/').slice(0, -1).join('/') || '/'; + html += ` +
+ + + + .. +
+ `; + } + + files.forEach(file => { + const isSelected = selectedFile && selectedFile.path === file.path; + const selectedClass = isSelected ? 'selected' : ''; + + if (file.is_dir) { + html += ` +
+ + + + ${file.name} +
+ `; + } else if (file.is_sql) { + html += ` +
+ + + + + ${file.name} + ${file.size_human} + ${file.mtime_human} +
+ `; + } else { + html += ` +
+ + + + ${file.name} + ${file.size_human} +
+ `; + } + }); + + html += '
'; + container.innerHTML = html; +} + +function selectFile(file) { + selectedFile = file; + document.getElementById('selected-file-info').innerHTML = ` +

${file.name}

+

${file.size_human} - ${file.mtime_human}

+ `; + document.getElementById('restore-btn').disabled = false; + + // Auto-detect target database from filename + const filename = file.name.toLowerCase(); + const targetDbSelect = document.getElementById('target-db'); + const availableDbs = Array.from(targetDbSelect.options).map(o => o.value); + + for (const db of availableDbs) { + if (filename.includes(db.toLowerCase())) { + targetDbSelect.value = db; + break; + } + } + + // Re-render to show selection + browse(currentPath); +} + +function updateBreadcrumb(path) { + const parts = path.split('/').filter(p => p); + let html = `/`; + + let currentPathBuild = ''; + parts.forEach((part, index) => { + currentPathBuild += '/' + part; + const isLast = index === parts.length - 1; + html += ` + / + ${part} + `; + }); + + document.getElementById('breadcrumb').innerHTML = html; +} + +async function startRestore() { + if (!selectedFile) return; + + const targetDb = document.getElementById('target-db').value; + + if (!confirm(`Are you sure you want to restore ${selectedFile.name} to database "${targetDb}"?\n\nThis will DROP ALL TABLES in the database!`)) { + return; + } + + const btn = document.getElementById('restore-btn'); + btn.disabled = true; + btn.textContent = 'Starting...'; + + try { + const response = await fetch('/api/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file: selectedFile.path, + database: targetDb + }) + }); + + const data = await response.json(); + + if (data.success) { + currentJobId = data.job_id; + showProgressPanel(); + startPolling(); + } else { + showStatus('Restore failed: ' + data.error, 'error'); + btn.disabled = false; + btn.textContent = 'Start Restore'; + } + } catch (error) { + showStatus('Restore error: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = 'Start Restore'; + } +} + +function showProgressPanel() { + document.getElementById('progress-panel').classList.remove('hidden'); + document.getElementById('progress-bar').style.width = '0%'; + document.getElementById('progress-bar').classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600'); + document.getElementById('progress-bar').classList.add('bg-blue-600'); + document.getElementById('progress-percent').textContent = '0%'; + document.getElementById('progress-status').textContent = 'Starting...'; + document.getElementById('progress-message').textContent = 'Initializing...'; + document.getElementById('cancel-btn').classList.remove('hidden'); + document.getElementById('cancel-btn').disabled = false; + document.getElementById('cancel-btn').textContent = 'Cancel Restore'; +} + +function startPolling() { + if (pollInterval) clearInterval(pollInterval); + + pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/status/${currentJobId}`); + const data = await response.json(); + + if (data.success) { + updateProgress(data); + + if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { + stopPolling(); + document.getElementById('restore-btn').disabled = false; + document.getElementById('restore-btn').textContent = 'Start Restore'; + + if (data.status === 'completed') { + showStatus('Restore completed successfully!', 'success'); + } else if (data.status === 'cancelled') { + showStatus('Restore was cancelled', 'error'); + } else { + showStatus('Restore failed: ' + data.error, 'error'); + } + } + } + } catch (error) { + console.error('Polling error:', error); + } + }, 250); +} + +function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } +} + +async function cancelRestore() { + if (!currentJobId) return; + + if (!confirm('Are you sure you want to cancel the restore?')) { + return; + } + + const btn = document.getElementById('cancel-btn'); + btn.disabled = true; + btn.textContent = 'Cancelling...'; + + try { + const response = await fetch(`/api/cancel/${currentJobId}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + showStatus('Cancellation requested...', 'error'); + } else { + showStatus('Cancel failed: ' + data.error, 'error'); + btn.disabled = false; + btn.textContent = 'Cancel Restore'; + } + } catch (error) { + showStatus('Cancel error: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = 'Cancel Restore'; + } +} + +function updateProgress(data) { + const bar = document.getElementById('progress-bar'); + const percent = document.getElementById('progress-percent'); + const status = document.getElementById('progress-status'); + const message = document.getElementById('progress-message'); + const elapsed = document.getElementById('progress-elapsed'); + const cancelBtn = document.getElementById('cancel-btn'); + + bar.style.width = data.progress + '%'; + percent.textContent = data.progress + '%'; + + // Update status label + const statusLabels = { + 'starting': 'Starting', + 'downloading': 'Downloading', + 'restoring': 'Restoring', + 'completed': 'Completed', + 'error': 'Error', + 'cancelled': 'Cancelled' + }; + status.textContent = statusLabels[data.status] || data.status; + + // Update color based on status + bar.classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600'); + if (data.status === 'completed') { + bar.classList.remove('bg-blue-600'); + bar.classList.add('bg-green-600'); + } else if (data.status === 'error') { + bar.classList.remove('bg-blue-600'); + bar.classList.add('bg-red-600'); + } else if (data.status === 'cancelled') { + bar.classList.remove('bg-blue-600'); + bar.classList.add('bg-yellow-600'); + } + + // Show/hide cancel button based on job status + if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { + cancelBtn.classList.add('hidden'); + } else { + cancelBtn.classList.remove('hidden'); + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Restore'; + } + + message.textContent = data.message || ''; + if (data.elapsed) { + elapsed.textContent = 'Elapsed: ' + data.elapsed; + } +} + +function showStatus(message, type) { + const status = document.getElementById('connection-status'); + status.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800'); + + if (type === 'success') { + status.classList.add('bg-green-100', 'text-green-800'); + } else if (type === 'error') { + status.classList.add('bg-red-100', 'text-red-800'); + } + + status.textContent = message; +} + +function hideStatus() { + document.getElementById('connection-status').classList.add('hidden'); +} diff --git a/docker/db-downloader/templates/index.html b/docker/db-downloader/templates/index.html new file mode 100644 index 000000000..1e5909e3d --- /dev/null +++ b/docker/db-downloader/templates/index.html @@ -0,0 +1,189 @@ + + + + + + DB Restore Tool + + + + +
+ +
+

Database Restore Tool

+

Browse and restore database backups from remote server

+
+ +
+ +
+
+

+ + + + Connection +

+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ + + + + + + +
+
+ + +
+

+ + + + Restore +

+ +
+

No file selected

+
+ +
+ + +
+ +
+

+ Warning: This will DROP all tables in the selected database before restoring! +

+
+ + +
+
+ + +
+
+

+ + + + Remote Browser +

+ + + + + +
+
+ + + +

Connect to browse remote files

+
+
+
+ + + +
+
+
+ + + + From d29c30ee2da591f5404d0559dbbbb17d671508d8 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 29 Dec 2025 07:35:14 +0100 Subject: [PATCH 02/88] added new db downloader --- docker-compose.yml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2748ee516..c81fccc3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,8 +32,6 @@ services: image: adminer ports: - "8088:8080" - volumes: - - ./docker/adminer/php.ini:/etc/php/7.4/cli/conf.d/php.local.ini phpmyadmin: image: phpmyadmin @@ -41,11 +39,30 @@ services: - "8081:80" environment: - PMA_HOST=db - - PMA_UPLOAD_LIMIT=1G - - UPLOAD_LIMIT=1G - MYSQL_ROOT_PASSWORD=junghan5 depends_on: - db + db-downloader: + build: + context: ./docker/db-downloader + dockerfile: Dockerfile + ports: + - "8082:8082" + # volumes: + # - ./docker/db-downloader/ssh-keys:/app/ssh-keys:ro + environment: + - SCP_HOST=thetool-dbbackup.xinon.at + - SCP_PORT=22 + - SCP_USERNAME=xinon + - SCP_DEFAULT_PATH=/opt/backup/mysql + - DB_HOST=db + - DB_PORT=3306 + - DB_USER=root + - DB_PASSWORD=junghan5 + - DB_AVAILABLE=thetool,addressdb + depends_on: + - db + volumes: vendor: From 72bcde631d491f5d07d6dbfdc74679ca71e2976f Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 29 Dec 2025 08:02:53 +0100 Subject: [PATCH 03/88] fixed ont info --- Layout/default/Device/Detail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Layout/default/Device/Detail.php b/Layout/default/Device/Detail.php index afa91e9b1..c4d393830 100644 --- a/Layout/default/Device/Detail.php +++ b/Layout/default/Device/Detail.php @@ -711,7 +711,7 @@ foreach ($devicesall as $deviceall) { -devicetype->devicemanufactor->config_backup > count()): +devicetype->devicemanufactor->config_backup > 0): $year = date("Y", time()); $month = date("n", time()); From bc2c62bbddf979ff68039bbb687d6c1e1bb5d8e2 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 29 Dec 2025 10:26:48 +0100 Subject: [PATCH 04/88] fixed ont info --- .../default/WarehouseArticle/LABEL_BULK.php | 54 +++++++++++++++++++ .../WarehouseArticleController.php | 30 ++++++++++- .../WarehouseCategoryController.php | 34 +++++++++++- .../WarehouseArticle/WarehouseArticle.js | 1 - .../WarehouseCategory/WarehouseCategory.js | 23 ++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 Layout/default/WarehouseArticle/LABEL_BULK.php create mode 100644 public/js/pages/WarehouseCategory/WarehouseCategory.js diff --git a/Layout/default/WarehouseArticle/LABEL_BULK.php b/Layout/default/WarehouseArticle/LABEL_BULK.php new file mode 100644 index 000000000..12ebdd864 --- /dev/null +++ b/Layout/default/WarehouseArticle/LABEL_BULK.php @@ -0,0 +1,54 @@ + QRCode::OUTPUT_IMAGE_PNG, + 'scale' => 10, + 'quietzoneSize' => 1, +]); +$qrcode = new QRCode($options); +?> + + + + + + +id . ":" . $article->articleNumber; + $qrCodeBase64 = $qrcode->render($qrData); +?> +
+ + + + + +
+ + + +
articleNumber); ?>
+
title); ?>
+
+
+ + + diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index c80214bfd..a33c825d5 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -111,7 +111,7 @@ class WarehouseArticleController extends TTCrud { if ($categoryId) { $category = WarehouseCategory::get($categoryId); if ($category && $category->articleNumberPrefix) { - $expectedPrefix = $category->articleNumberPrefix; + $expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT); $articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix)); if ($articlePrefix !== $expectedPrefix) { self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen."); @@ -178,7 +178,7 @@ class WarehouseArticleController extends TTCrud { if (!$category) self::sendError("Kategorie nicht gefunden"); if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix"); - $prefix = $category->articleNumberPrefix; + $prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT); $db = FronkDB::singleton(); // Get all existing article numbers with this prefix, sorted @@ -262,4 +262,30 @@ class WarehouseArticleController extends TTCrud { readfile($filename); die(); } + + protected function printLabelsByCategoryAction() { + $categoryId = intval($this->request->categoryId); + if (!$categoryId) { + self::sendError("Kategorie nicht angegeben", 400); + } + + $articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']); + if (empty($articles)) { + self::sendError("Keine Artikel in dieser Kategorie gefunden", 404); + } + + $pdf_vars = ['articles' => $articles]; + $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + $category = WarehouseCategory::get($categoryId); + $categoryName = $category ? $category->name : 'category-' . $categoryId; + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"'); + readfile($filename); + die(); + } } diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php index 26d16ab83..bf396801d 100644 --- a/application/WarehouseCategory/WarehouseCategoryController.php +++ b/application/WarehouseCategory/WarehouseCategoryController.php @@ -16,7 +16,39 @@ class WarehouseCategoryController extends TTCrud { ]; // @formatter:on - protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']]; + protected array $additionalActions = [ + ['key' => 'printLabels', 'title' => 'Labels drucken', 'class' => 'fas fa-print text-primary'], + ['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'] + ]; + + protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true]; + + public function printLabelsAction() { + $categoryId = intval($this->request->id); + $articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']); + + if (empty($articles)) { + echo "Keine Artikel in dieser Kategorie."; + die(); + } + + $pdf_vars = [ + 'articles' => $articles + ]; + + $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars); + $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + + $filename = $pdf->render($wkhtmltopdfArgs); + + $category = WarehouseCategory::get($categoryId); + $categoryName = $category ? $category->name : 'category-' . $categoryId; + + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"'); + readfile($filename); + die(); + } protected function beforeCreate(): bool { $this->postData['articleNumberPrefix'] = $this->getNextFreePrefix(); diff --git a/public/js/pages/WarehouseArticle/WarehouseArticle.js b/public/js/pages/WarehouseArticle/WarehouseArticle.js index 362a3943b..b8ad34990 100644 --- a/public/js/pages/WarehouseArticle/WarehouseArticle.js +++ b/public/js/pages/WarehouseArticle/WarehouseArticle.js @@ -423,7 +423,6 @@ Vue.component('warehouse-article-modal', { v-model="formData.articleNumber" placeholder="Wird automatisch generiert" required - disabled form-label sm/> diff --git a/public/js/pages/WarehouseCategory/WarehouseCategory.js b/public/js/pages/WarehouseCategory/WarehouseCategory.js new file mode 100644 index 000000000..adc23ef5e --- /dev/null +++ b/public/js/pages/WarehouseCategory/WarehouseCategory.js @@ -0,0 +1,23 @@ +Vue.component('warehouse-category', { + //language=Vue + template: ` + + + + + + `, data() { + return { + window: window, historyModal: false, historyModalId: null, + } + }, + methods: { + printLabels(event) { + const url = window.TT_CONFIG.BASE_PATH + "/WarehouseCategory/printLabels?id=" + event.id; + window.open(url, '_blank'); + } + } +}) From e2251c3073ed0c88b3392c26077b38e85bf574f9 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 29 Dec 2025 11:36:50 +0100 Subject: [PATCH 05/88] fixed printing --- Layout/default/WarehouseArticle/LABEL.php | 14 +++++++------- Layout/default/WarehouseArticle/LABEL_BULK.php | 14 +++++++------- .../WarehouseArticleController.php | 4 ++-- .../WarehouseCategoryController.php | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Layout/default/WarehouseArticle/LABEL.php b/Layout/default/WarehouseArticle/LABEL.php index 4de88a8e9..cb60423b6 100644 --- a/Layout/default/WarehouseArticle/LABEL.php +++ b/Layout/default/WarehouseArticle/LABEL.php @@ -18,21 +18,21 @@ $qrCodeBase64 = (new QRCode($options))->render($qrData); - +
-
- + + - -
-
+ +
+
diff --git a/Layout/default/WarehouseArticle/LABEL_BULK.php b/Layout/default/WarehouseArticle/LABEL_BULK.php index 12ebdd864..b6fa823c7 100644 --- a/Layout/default/WarehouseArticle/LABEL_BULK.php +++ b/Layout/default/WarehouseArticle/LABEL_BULK.php @@ -20,7 +20,7 @@ $qrcode = new QRCode($options); table { border-collapse: collapse; } .label-page { height: 25mm; - width: 50mm; + width: 63mm; overflow: hidden; page-break-after: always; } @@ -36,15 +36,15 @@ $qrcode = new QRCode($options); $qrCodeBase64 = $qrcode->render($qrData); ?>
- +
-
- + + - -
articleNumber); ?>
-
title); ?>
+ +
articleNumber); ?>
+
title); ?>
diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index a33c825d5..aa09bdc00 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -253,7 +253,7 @@ class WarehouseArticleController extends TTCrud { ]; $pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars); - $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; $filename = $pdf->render($wkhtmltopdfArgs); @@ -276,7 +276,7 @@ class WarehouseArticleController extends TTCrud { $pdf_vars = ['articles' => $articles]; $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars); - $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; $filename = $pdf->render($wkhtmltopdfArgs); diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php index bf396801d..40fe2a231 100644 --- a/application/WarehouseCategory/WarehouseCategoryController.php +++ b/application/WarehouseCategory/WarehouseCategoryController.php @@ -37,7 +37,7 @@ class WarehouseCategoryController extends TTCrud { ]; $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars); - $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; + $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96"; $filename = $pdf->render($wkhtmltopdfArgs); From 42f79ed9f847946d3dadb9fb158cb97d5f47a230 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 29 Dec 2025 14:24:58 +0100 Subject: [PATCH 06/88] restricted user management to a couple of users --- application/User/UserController.php | 22 ++++++++++++++++++++- public/js/pages/User/User.js | 30 ++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/application/User/UserController.php b/application/User/UserController.php index 06c1ba42e..eb9f27e23 100644 --- a/application/User/UserController.php +++ b/application/User/UserController.php @@ -12,6 +12,9 @@ class UserController extends mfBaseController { private $me; + // User IDs allowed to manage (add/edit/delete) users + private const ALLOWED_USER_MANAGER_IDS = [2, 5, 9, 6, 89, 145, 24]; + protected function init($request = null) { $this->needlogin = true; @@ -24,6 +27,11 @@ class UserController extends mfBaseController if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true); } + private function canManageUsers(): bool + { + return in_array($this->me->id, self::ALLOWED_USER_MANAGER_IDS); + } + protected function indexAction($request) { if (!$this->isAdmin()) { @@ -32,6 +40,7 @@ class UserController extends mfBaseController Helper::renderVue($this, "User", "Benutzer", [ "IS_ADMIN" => $this->me->isAdmin(), + "CAN_MANAGE_USERS" => $this->canManageUsers(), "USERS" => array_map(fn($user) => [ "username" => $user->username, "name" => $user->name, @@ -53,6 +62,7 @@ class UserController extends mfBaseController protected function formAction() { if (!$this->isAdmin()) $this->redirect("Dashboard"); + if (!$this->canManageUsers()) $this->redirect("User"); $id = $this->request->id; $user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User(); @@ -178,6 +188,7 @@ class UserController extends mfBaseController protected function generateApikeyAction($request) { if (!$this->isAdmin()) $this->redirect("Dashboard"); + if (!$this->canManageUsers()) $this->redirect("User"); $id = $request['id']; if (!is_numeric($id) || $id < 1) { @@ -207,6 +218,11 @@ class UserController extends mfBaseController unset($r->address_id); } + // Only allowed users can create/edit other users + if ($this->isAdmin() && !$this->canManageUsers()) { + self::redirect('User'); + } + if (!$id && !$r->username) self::redirect('User'); $user = new User($id); @@ -569,7 +585,7 @@ class UserController extends mfBaseController } protected function impersonateAction() { - if(!$this->me->isAdmin() || $this->me->address_id != 1) { + if(!$this->me->isAdmin() || $this->me->address_id != 1 || !$this->canManageUsers()) { header("HTTP/1.1 403 Forbidden"); exit; } @@ -590,6 +606,10 @@ class UserController extends mfBaseController protected function sendLoginEmailAction() { + if (!$this->canManageUsers()) { + self::sendError("Keine Berechtigung."); + } + $id = $this->request->id; if (!$id || !is_numeric($id)) { self::sendError("Benutzer-ID fehlt oder ist ungültig."); diff --git a/public/js/pages/User/User.js b/public/js/pages/User/User.js index b39a9b265..6cb411b16 100644 --- a/public/js/pages/User/User.js +++ b/public/js/pages/User/User.js @@ -3,14 +3,14 @@ Vue.component("User", {
-