Remove outdated templates and scripts; introduce new Docker and Nginx management scripts

- Deleted old login and main HTML templates to streamline the user interface.
- Removed test script and verification logic as part of the cleanup.
- Added new `gitea.sh` and `nginx.sh` scripts for managing Docker services and Nginx configurations.
- Created Nginx configuration templates for Gitea and website services to facilitate easier deployment.
- Updated README with instructions for using the new scripts and managing services.
This commit is contained in:
2026-03-27 23:45:00 +01:00
parent 138dac73f3
commit 94c56a8620
19 changed files with 560 additions and 1450 deletions
@@ -1,43 +0,0 @@
import os
import json
from pathlib import Path
LICENSES_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__),"licenses.json"))
def load_licenses() -> dict:
"""Load licenses.json file and return its content."""
try:
with open(LICENSES_FILE, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_licenses(data: dict) -> bool:
"""Save licenses data to licenses.json file."""
try:
os.makedirs(os.path.dirname(LICENSES_FILE), exist_ok=True)
with open(LICENSES_FILE, "w") as f:
json.dump(data, f, indent=2)
return True
except Exception as e:
print(f"Error saving licenses: {e}")
return False
def export_backup() -> dict:
"""Export current licenses.json content."""
return load_licenses()
def import_backup(data: dict) -> bool:
"""Import and restore licenses from backup data."""
try:
if not isinstance(data, list):
return False
save_licenses(data)
return True
except Exception as e:
print(f"Error importing backup: {e}")
return False
@@ -1,17 +0,0 @@
[
{
"user_id": "0001",
"license_key": "npEDI1JVhyisQ2dg-fWTTAn5H1hg3eS80Zc-4IptWIU=",
"hwid_uuid": "0c27aefd7107396933c7363efa38fb6c297aa3db119c1597952c05d947f4f27c"
},
{
"user_id": "0002",
"license_key": "qclcig8LitaXJGC4zqcZ3xFULlUinzGAOBYB07YGrTA=",
"hwid_uuid": "0c27aefd7107396933c7363efa38fb6c297aa3db119c1597952c05d947f4f37c"
},
{
"user_id": "0003",
"license_key": "H02JSHkh9qtnzU8D5yBVcyej98AU-R6lVhwyYfHA1uM",
"hwid_uuid": ""
}
]
-234
View File
@@ -1,234 +0,0 @@
from pathlib import Path
import sys
import os
from datetime import timedelta
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session, send_file
import verify
import backup
import pyotp
import qrcode
import json
from io import BytesIO
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
def _resolve_template_dir() -> Path:
candidates = [
Path(__file__).resolve().parent / "templates",
Path(sys.argv[0]).resolve().parent / "templates",
Path.cwd() / "templates",
]
for path in candidates:
if path.is_dir():
return path
return candidates[0]
totp_key = "Hsdfisdf4n34234dfiseLoasjfj3asnnvhxbbfgrzzuewwndcodrweokyn"
app = Flask(__name__, template_folder=str(_resolve_template_dir()))
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s"
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", app.secret_key)
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=12)
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
jwt = JWTManager(app)
@app.after_request
def apply_security_headers(response):
# Apply baseline hardening headers for all endpoints.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'")
# Emit HSTS only for HTTPS requests to avoid breaking local HTTP development.
if request.is_secure:
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return response
def _read_app_version() -> str:
candidates = [
Path(__file__).resolve().parent / "version.txt",
Path(sys.argv[0]).resolve().parent / "version.txt",
Path.cwd() / "version.txt",
]
for path in candidates:
if path.is_file():
return path.read_text(encoding="utf-8").strip() or "unknown"
return "unknown"
def generate_totp_qrcode():
uri = pyotp.totp.TOTP(totp_key).provisioning_uri(name='',issuer_name='Inventarsystem Lizenz Verwaltung')
qrcode.make(uri).save("qr.png")
def check_totp(key):
totp = pyotp.TOTP(totp_key)
return totp.verify(key)
APP_VERSION = _read_app_version()
def _issue_access_token() -> str:
return create_access_token(identity="license-validation-client")
@app.route('/access_token', methods=['POST'])
def access_token():
data = request.get_json(silent=True) or {}
provided_totp = data.get("totp") or request.form.get("totp") or request.form.get("password")
if not provided_totp:
return jsonify({"error": "Missing 'totp' in request"}), 400
if not check_totp(str(provided_totp)):
return jsonify({"error": "Invalid credentials"}), 401
token = _issue_access_token()
return jsonify({"access_token": token, "token_type": "Bearer"}), 200
@app.route("/validate__information", methods=['POST'])
def validate__information():
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "No JSON data provided"}), 400
license_key = data.get("license")
hwid_uuid = data.get("hwid")
if not license_key or not hwid_uuid:
return jsonify({"error": "Missing 'license' or 'hwid' in JSON data"}), 400
if verify.check(license_key, hwid_uuid):
return jsonify({"status": "ok"}), 200
else:
return jsonify({"status": "invalid"}), 402
@app.route('/login', methods=['GET', 'POST'])
def login():
if 'username' in session:
return redirect(url_for('default'))
if request.method == 'POST':
data = request.get_json(silent=True) or {}
totp = data.get("totp") or request.form.get('password') or request.form.get('totp')
if not totp:
if request.is_json:
return jsonify({"error": "Missing TOTP"}), 400
flash('Please fill all fields', 'error')
return redirect(url_for('login'))
user_log = check_totp(totp)
if user_log:
session['username'] = "Whatareyoulookingfor"
session['access_token'] = _issue_access_token()
if request.is_json:
return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200
return redirect(url_for('default'))
else:
if request.is_json:
return jsonify({"error": "Invalid credentials"}), 401
flash('Invalid credentials', 'error')
get_flashed_messages()
return render_template('login.html')
@app.route("/")
def default():
if 'username' not in session:
return redirect(url_for('login'))
keys = verify.load_file()
return render_template("main.html", keys=keys)
@app.route("/generate_new", methods=["POST"])
def generate_new():
if 'username' not in session:
return redirect(url_for('login'))
new_license = verify.new_key()
flash(f"New key generated: {new_license}", "success")
return redirect(url_for('default'))
@app.route("/remove_key/<user_id>", methods=["POST"])
def remove_key(user_id):
if 'username' not in session:
return redirect(url_for('login'))
if verify.remove_key(user_id):
flash(f"Key for user {user_id} removed", "success")
else:
flash(f"No key found for user {user_id}", "error")
return redirect(url_for('default'))
@app.route('/logout')
def logout():
session.pop('username', None)
session.pop('access_token', None)
flash('Logged out successfully', 'info')
return redirect(url_for('login'))
@app.route('/download_backup', methods=['GET'])
def download_backup():
if 'username' not in session:
return redirect(url_for('login'))
licenses_data = backup.export_backup()
json_str = json.dumps(licenses_data, indent=2)
return send_file(
BytesIO(json_str.encode('utf-8')),
mimetype='application/json',
as_attachment=True,
download_name='licenses_backup.json'
)
@app.route('/upload_backup', methods=['POST'])
def upload_backup():
if 'username' not in session:
return redirect(url_for('login'))
if 'file' not in request.files:
flash('No file provided', 'error')
return redirect(url_for('default'))
file = request.files['file']
if file.filename == '':
flash('No file selected', 'error')
return redirect(url_for('default'))
try:
content = file.read().decode('utf-8')
data = json.loads(content)
if backup.import_backup(data):
flash('Licenses backup restored successfully', 'success')
else:
flash('Failed to restore backup - invalid format', 'error')
except json.JSONDecodeError:
flash('Invalid JSON file', 'error')
except Exception as e:
flash(f'Error uploading backup: {str(e)}', 'error')
return redirect(url_for('default'))
def main():
app.run(host="0.0.0.0", port=5000, debug=False)
if __name__ == "__main__":
main()
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && command -v conda >/dev/null 2>&1; then
conda deactivate || true
fi
source .venv/bin/activate
# Do not run apt here because third-party repos in dev containers can fail.
# We only check and provide the install hint if patchelf is missing.
if ! command -v patchelf >/dev/null 2>&1; then
echo "Error: patchelf is required for Nuitka standalone builds on Linux."
echo "Install with: sudo apt update && sudo apt install -y patchelf"
exit 1
fi
pip install -U nuitka ordered-set zstandard flask flask-jwt-extended cryptography pyotp qrcode
python - <<'PY'
from pathlib import Path
import json
root = Path.cwd()
settings_path = root / "settings.json"
version_file = root / "version.txt"
version = "0.1.0"
if settings_path.is_file():
raw = settings_path.read_text(encoding="utf-8").strip()
if raw:
data = json.loads(raw)
if isinstance(data, dict):
version = str(data.get("version", version)).strip() or version
elif isinstance(data, list):
for item in data:
if isinstance(item, dict) and "version" in item:
version = str(item.get("version", version)).strip() or version
break
version_file.write_text(f"{version}\n", encoding="utf-8")
print(f"Prepared {version_file} with version: {version}")
PY
python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=build --remove-output main.py
if [[ -x "./build/main.bin" ]]; then
./build/main.bin
else
echo "Build step finished but executable not found at ./build/main.bin"
exit 1
fi
@@ -1,401 +0,0 @@
/**
* iOS Specific Enhancements for Inventarsystem
*
* This file contains fixes specific to iOS devices for the Inventarsystem application,
* focusing on upload and duplication functionality.
*
* Copyright 2025 Maximilian Gründinger
* Licensed under the Apache License, Version 2.0
*/
// Initialize when document is loaded
document.addEventListener('DOMContentLoaded', function() {
if (isIOSDevice()) {
console.log('iOS device detected, applying iOS-specific enhancements');
applyIOSFixes();
}
});
// Apply all iOS-specific fixes
function applyIOSFixes() {
// Fix file input issues
fixIOSFileInputs();
// Fix form submission
fixIOSFormSubmission();
// Fix image handling
fixIOSImageHandling();
// Fix duplication
fixIOSDuplication();
// Apply CSS fixes
applyIOSCSSFixes();
}
// Fix iOS file input issues
function fixIOSFileInputs() {
// Find all file inputs
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => {
// Create a touch-friendly wrapper
const wrapper = document.createElement('div');
wrapper.className = 'ios-file-input-wrapper';
wrapper.style.position = 'relative';
// Style the original input to be more touch-friendly
input.style.padding = '20px 0';
// Wrap the input
if (input.parentNode) {
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
// Add a visible button for better touch target
const fakeButton = document.createElement('div');
fakeButton.className = 'ios-file-button';
fakeButton.textContent = 'Wählen Sie Bilder/Videos aus';
fakeButton.style.display = 'inline-block';
fakeButton.style.padding = '10px 15px';
fakeButton.style.backgroundColor = '#4CAF50';
fakeButton.style.color = 'white';
fakeButton.style.borderRadius = '4px';
fakeButton.style.textAlign = 'center';
fakeButton.style.margin = '10px 0';
fakeButton.style.fontWeight = 'bold';
wrapper.appendChild(fakeButton);
// Make sure the real input covers the fake button
input.style.position = 'absolute';
input.style.top = '0';
input.style.left = '0';
input.style.width = '100%';
input.style.height = '100%';
input.style.opacity = '0';
input.style.zIndex = '10';
}
// Add custom event handling
input.addEventListener('change', function() {
// Update visual feedback
const fileCount = this.files ? this.files.length : 0;
let fileText = fakeButton.textContent;
if (fileCount > 0) {
fileText = `${fileCount} Datei${fileCount !== 1 ? 'en' : ''} ausgewählt`;
fakeButton.style.backgroundColor = '#2E7D32';
} else {
fileText = 'Wählen Sie Bilder/Videos aus';
fakeButton.style.backgroundColor = '#4CAF50';
}
fakeButton.textContent = fileText;
});
});
}
// Fix iOS form submission issues
function fixIOSFormSubmission() {
// Find upload forms
const uploadForms = document.querySelectorAll('form[action*="upload_item"]');
uploadForms.forEach(form => {
// Add an iOS submission handler
form.addEventListener('submit', function(e) {
// Only intercept on iOS
if (!isIOSDevice()) return;
e.preventDefault();
// Show that we're processing
const loadingMessage = document.createElement('div');
loadingMessage.className = 'ios-loading-message';
loadingMessage.textContent = 'Wird verarbeitet...';
loadingMessage.style.padding = '10px';
loadingMessage.style.backgroundColor = '#f0f0f0';
loadingMessage.style.borderRadius = '5px';
loadingMessage.style.margin = '10px 0';
loadingMessage.style.textAlign = 'center';
form.appendChild(loadingMessage);
// Disable the submit button
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
// Use timeout to allow UI to update before heavy processing
setTimeout(() => {
// Gather form data with special handling for iOS
const formData = new FormData(form);
// Add iOS flag
formData.append('is_ios', 'true');
// Submit with fetch API which works better on iOS
fetch(form.action, {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
// Remove loading message
if (loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
}
// Re-enable submit button
if (submitButton) {
submitButton.disabled = false;
}
if (data.success) {
// Show success and redirect
alert(data.message || 'Element erfolgreich hinzugefügt');
// Redirect to the appropriate page
if (data.itemId) {
window.location.href = `/home_admin?highlight_item=${data.itemId}`;
} else {
window.location.href = '/home_admin';
}
} else {
// Show error
alert(data.message || 'Ein Fehler ist aufgetreten');
}
})
.catch(error => {
console.error('iOS form submission error:', error);
// Remove loading message
if (loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
}
// Re-enable submit button
if (submitButton) {
submitButton.disabled = false;
}
// Show error
alert('Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.');
});
}, 100);
});
});
}
// Fix iOS image handling issues
function fixIOSImageHandling() {
// Reduce image preview quality on iOS
if (typeof ImagePreviewGenerator !== 'undefined') {
// Override the compression quality for iOS
const originalCompressImage = ImagePreviewGenerator.prototype._compressImage;
if (originalCompressImage) {
ImagePreviewGenerator.prototype._compressImage = function(dataUrl) {
// Lower quality for iOS
this.quality = 0.5;
this.maxWidth = 600;
this.maxHeight = 600;
return originalCompressImage.call(this, dataUrl);
};
}
}
// Fix image preview display
document.querySelectorAll('.image-preview-container').forEach(container => {
// Add iOS-specific styling
container.style.webkitOverflowScrolling = 'touch';
container.style.maxHeight = '150px';
container.style.overflow = 'auto';
});
}
// Fix iOS duplication issues
function fixIOSDuplication() {
// Override duplicateItem function
if (typeof duplicateItem !== 'undefined') {
const originalDuplicateItem = duplicateItem;
duplicateItem = function(itemId) {
// Use localStorage on iOS instead of sessionStorage
const useFunction = originalDuplicateItem;
useFunction(itemId);
// Additional iOS-specific handling
const checkForSessionData = setInterval(() => {
const sessionData = sessionStorage.getItem('duplicateItemData');
if (sessionData) {
// Copy from sessionStorage to localStorage
try {
localStorage.setItem('duplicateItemData', sessionData);
localStorage.setItem('duplicateItemTimestamp', Date.now().toString());
console.log('Copied duplicate data to localStorage for iOS reliability');
} catch (e) {
console.warn('Failed to copy to localStorage:', e);
}
clearInterval(checkForSessionData);
}
}, 100);
// Clear interval after 3 seconds regardless
setTimeout(() => clearInterval(checkForSessionData), 3000);
};
}
// Fix prefill function
if (typeof prefillFormWithDuplicateData !== 'undefined') {
const originalPrefill = prefillFormWithDuplicateData;
prefillFormWithDuplicateData = function() {
// Try to get data from localStorage first
let data = null;
try {
const localData = localStorage.getItem('duplicateItemData');
if (localData) {
data = JSON.parse(localData);
localStorage.removeItem('duplicateItemData');
localStorage.removeItem('duplicateItemTimestamp');
console.log('Using duplicate data from localStorage');
}
} catch (e) {
console.warn('Error accessing localStorage:', e);
}
// If we got data from localStorage, manually populate the form
if (data) {
console.log('Manually populating form with iOS optimization');
// Basic fields
document.getElementById('name').value = data.name || '';
document.getElementById('beschreibung').value = data.description || '';
// Handle filters with a delay
setTimeout(() => {
// Filter 1
if (data.filter1 && Array.isArray(data.filter1)) {
data.filter1.forEach((val, idx) => {
const field = document.getElementById(`filter1-${idx+1}`);
if (field && val) field.value = val;
});
}
// Filter 2
if (data.filter2 && Array.isArray(data.filter2)) {
data.filter2.forEach((val, idx) => {
const field = document.getElementById(`filter2-${idx+1}`);
if (field && val) field.value = val;
});
}
// Filter 3
if (data.filter3 && Array.isArray(data.filter3)) {
data.filter3.forEach((val, idx) => {
const field = document.getElementById(`filter3-${idx+1}`);
if (field && val) field.value = val;
});
}
}, 300);
// Location
setTimeout(() => {
const location = document.getElementById('ort');
if (location && data.location) location.value = data.location;
}, 400);
// Year and cost
const year = document.getElementById('anschaffungsjahr');
if (year && data.year) year.value = data.year;
const cost = document.getElementById('anschaffungskosten');
if (cost && data.cost) cost.value = data.cost;
// Handle images - limited for iOS
const form = document.querySelector('form');
if (form && data.images && Array.isArray(data.images)) {
// Add hidden field to indicate duplication
const isDuplicatingField = document.createElement('input');
isDuplicatingField.type = 'hidden';
isDuplicatingField.name = 'is_duplicating';
isDuplicatingField.value = 'true';
form.appendChild(isDuplicatingField);
// Only use first 3 images on iOS for performance
const limitedImages = data.images.slice(0, 3);
limitedImages.forEach(imageName => {
const imageField = document.createElement('input');
imageField.type = 'hidden';
imageField.name = 'duplicate_images';
imageField.value = imageName;
form.appendChild(imageField);
});
// Add a message about limited images
if (data.images.length > 3) {
const messageDiv = document.createElement('div');
messageDiv.style.margin = '10px 0';
messageDiv.style.padding = '10px';
messageDiv.style.backgroundColor = '#fff3cd';
messageDiv.style.borderRadius = '5px';
messageDiv.style.color = '#856404';
messageDiv.textContent = `Aus Leistungsgründen werden nur ${limitedImages.length} von ${data.images.length} Bildern verwendet`;
const fileInput = document.getElementById('images');
if (fileInput && fileInput.parentNode) {
fileInput.parentNode.appendChild(messageDiv);
} else {
form.appendChild(messageDiv);
}
}
}
} else {
// Use original function as fallback
originalPrefill();
}
};
}
}
// Apply iOS-specific CSS fixes
function applyIOSCSSFixes() {
// Create a style element
const style = document.createElement('style');
style.textContent = `
/* Improve touch targets for iOS */
button, select, input[type="submit"], .ausleihen, .edit-button,
.delete-button, .duplicate-button, .details-button {
min-height: 44px !important;
padding: 10px 15px !important;
}
/* Improve scrolling on iOS */
.items-container, .filter-options, .modal-content {
-webkit-overflow-scrolling: touch !important;
}
/* Prevent zoom on inputs */
input, select, textarea {
font-size: 16px !important;
}
/* Fix iOS image display */
.item-image {
-webkit-transform: translateZ(0);
}
/* Improve form fields on iOS */
.form-group {
margin-bottom: 15px !important;
}
`;
document.head.appendChild(style);
}
@@ -1,286 +0,0 @@
/**
* Mobile Compatibility Utilities
*
* This module provides utility functions for better mobile device compatibility,
* especially for iOS devices where certain browser APIs have limited support.
*
* Copyright 2025 Maximilian Gründinger
* Licensed under the Apache License, Version 2.0
*/
// Detect mobile devices including tablets
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// Specifically detect iOS devices
function isIOSDevice() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Mobile-compatible file tracker to replace DataTransfer API
class MobileCompatFileTracker {
constructor() {
this.files = [];
this.removedIndices = new Set();
}
// Add files from a FileList
addFiles(fileList) {
// Convert FileList to Array for easier handling
Array.from(fileList).forEach(file => {
this.files.push(file);
});
}
// Remove a file by index
removeFile(index) {
if (index >= 0 && index < this.files.length) {
this.removedIndices.add(index);
}
}
// Get all non-removed files
getFiles() {
return this.files.filter((_, index) => !this.removedIndices.has(index));
}
// Convert to FormData for upload
appendToFormData(formData, fieldName) {
this.getFiles().forEach(file => {
formData.append(fieldName, file);
});
}
// Clear all files
clear() {
this.files = [];
this.removedIndices.clear();
}
}
// Mobile optimized image preview generator
class ImagePreviewGenerator {
constructor(options = {}) {
this.maxWidth = options.maxWidth || 800;
this.maxHeight = options.maxHeight || 600;
this.quality = options.quality || 0.8;
this.maxPreviewsOnMobile = options.maxPreviewsOnMobile || 3;
}
// Create optimized image preview element
createPreview(file, index) {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file provided'));
return;
}
const isVideo = file.type.startsWith('video/');
if (isVideo) {
this._createVideoPreview(file, index).then(resolve).catch(reject);
} else if (file.type.startsWith('image/')) {
this._createImagePreview(file, index).then(resolve).catch(reject);
} else {
reject(new Error('Unsupported file type'));
}
});
}
// Create image preview with optimization for mobile
_createImagePreview(file, index) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
// For mobile, we'll compress the image before creating the preview
if (isMobileDevice()) {
this._compressImage(e.target.result)
.then(compressedDataUrl => {
const previewElement = this._createPreviewElement(compressedDataUrl, file.name, index, false);
resolve(previewElement);
})
.catch(err => {
// Fallback to original if compression fails
console.warn('Image compression failed, using original:', err);
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
resolve(previewElement);
});
} else {
// For desktop, use the original image
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
resolve(previewElement);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
// Create video preview with thumbnail generation
_createVideoPreview(file, index) {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.src = videoUrl;
video.onloadedmetadata = () => {
// Create preview element
const previewElement = this._createPreviewElement(videoUrl, file.name, index, true);
resolve(previewElement);
// Revoke object URL after a delay to ensure it's loaded
setTimeout(() => {
URL.revokeObjectURL(videoUrl);
}, 3000);
};
video.onerror = () => {
URL.revokeObjectURL(videoUrl);
reject(new Error('Failed to load video'));
};
// Set a timeout in case the video never loads
setTimeout(() => {
if (!video.videoWidth) {
URL.revokeObjectURL(videoUrl);
reject(new Error('Video load timeout'));
}
}, 5000);
});
}
// Create HTML element for preview
_createPreviewElement(src, fileName, index, isVideo) {
const previewDiv = document.createElement('div');
previewDiv.className = 'image-preview-item';
if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '150px';
video.style.maxHeight = '150px';
video.style.objectFit = 'cover';
previewDiv.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.alt = fileName;
img.style.maxWidth = '150px';
img.style.maxHeight = '150px';
img.style.objectFit = 'cover';
previewDiv.appendChild(img);
}
const controlsDiv = document.createElement('div');
controlsDiv.className = 'image-controls';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = 'Entfernen';
removeButton.style.background = '#dc3545';
removeButton.style.color = 'white';
removeButton.style.border = 'none';
removeButton.style.padding = '5px 10px';
removeButton.style.borderRadius = '3px';
removeButton.style.cursor = 'pointer';
removeButton.style.marginLeft = '10px';
removeButton.dataset.index = index;
removeButton.addEventListener('click', function() {
const indexToRemove = parseInt(this.dataset.index, 10);
// We'll define removeImagePreview elsewhere
if (typeof window.removeImagePreview === 'function') {
window.removeImagePreview(indexToRemove);
}
});
controlsDiv.appendChild(removeButton);
previewDiv.appendChild(controlsDiv);
return previewDiv;
}
// Compress image for mobile devices
_compressImage(dataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// Scale down if needed
if (width > this.maxWidth || height > this.maxHeight) {
const ratio = Math.min(this.maxWidth / width, this.maxHeight / height);
width *= ratio;
height *= ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Get compressed data URL
const compressedDataUrl = canvas.toDataURL('image/jpeg', this.quality);
// Clean up
canvas.width = 0;
canvas.height = 0;
resolve(compressedDataUrl);
};
img.onerror = () => reject(new Error('Failed to load image for compression'));
img.src = dataUrl;
});
}
}
// Mobile-optimized debounce function for event handlers
function debounce(func, wait = 300) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Mobile-safe promise-based setTimeout
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Log mobile-specific issues to console or server
function logMobileIssue(action, error) {
if (isMobileDevice()) {
const diagnosticInfo = {
action: action,
error: error.message,
browser: navigator.userAgent,
timestamp: new Date().toISOString(),
memoryInfo: performance?.memory ? {
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
totalJSHeapSize: performance.memory.totalJSHeapSize,
usedJSHeapSize: performance.memory.usedJSHeapSize
} : 'Not available'
};
console.error('Mobile Issue:', diagnosticInfo);
// Optionally send to server
fetch('/log_mobile_issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(diagnosticInfo)
}).catch(() => {
// Silently fail if logging fails
});
}
}
@@ -1,132 +0,0 @@
<!--
Copyright 2025 Maximilian Gründinger
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>{% block title %}Inventarsystem Lizenz Verwaltung{% endblock %}</title>
{% block head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
:root {
color-scheme: light dark;
--c-bg:#f4f6f9;--c-bg-alt:#ffffff;--c-surface:#ffffff;--c-surface-alt:#f1f5f9;--c-border:#d9dde3;--c-border-strong:#b7bcc4;--c-text:#1f2933;--c-text-soft:#4b5563;--c-text-faint:#6b7280;--c-accent:#2563eb;--c-accent-hover:#1d4ed8;--c-accent-active:#1942b3;--c-success:#059669;--c-success-hover:#047857;--c-danger:#dc2626;--c-danger-hover:#b91c1c;--c-warning:#d97706;--c-focus-ring:0 0 0 3px rgba(37,99,235,.35);--c-shadow-sm:0 1px 3px rgba(0,0,0,0.08);--c-shadow-md:0 4px 12px rgba(0,0,0,0.12);--c-shadow-lg:0 8px 28px rgba(0,0,0,0.18);--c-scroll-track:#e5e7eb;--c-scroll-thumb:#c0c6ce;--c-scroll-thumb-hover:#9aa2ac;--c-badge-bg:#eef2ff;--c-badge-text:#3730a3;--c-code-bg:#f8fafc;--c-overlay:rgba(17,24,39,0.45);--c-link:#2563eb;--c-link-hover:#1d4ed8;--radius-sm:6px;--radius-md:10px;--radius-lg:14px;--transition:.18s cubic-bezier(.4,0,.2,1)
}
@media (prefers-color-scheme: dark){:root{--c-bg:#0d1117;--c-bg-alt:#101723;--c-surface:#121c29;--c-surface-alt:#182432;--c-border:#2a3a49;--c-border-strong:#3a4d5e;--c-text:#e3e9f1;--c-text-soft:#c2ccd6;--c-text-faint:#94a3b4;--c-accent:#3b82f6;--c-accent-hover:#2563eb;--c-accent-active:#1d4ed8;--c-success:#10b981;--c-success-hover:#059669;--c-danger:#ef4444;--c-danger-hover:#dc2626;--c-warning:#f59e0b;--c-focus-ring:0 0 0 3px rgba(59,130,246,.45);--c-shadow-sm:0 1px 4px rgba(0,0,0,0.55);--c-shadow-md:0 4px 18px rgba(0,0,0,0.55);--c-shadow-lg:0 10px 38px rgba(0,0,0,0.6);--c-scroll-track:#1c2632;--c-scroll-thumb:#314254;--c-scroll-thumb-hover:#44576a;--c-badge-bg:#1e293b;--c-badge-text:#c7d2fe;--c-code-bg:#182432;--c-overlay:rgba(11,18,32,0.62);--c-link:#60a5fa;--c-link-hover:#3b82f6}}
html{box-sizing:border-box}*,*::before,*::after{box-sizing:inherit}body{min-height:100svh;margin:0;font-family:Arial,system-ui,sans-serif;background:var(--c-bg);color:var(--c-text);padding:0 0 30px 0;transition:background .3s,color .3s}
.container:not(.container-fluid){width:min(100% - 2rem,1200px);margin-inline:auto;padding-top:20px}
.fit-content{width:fit-content;max-width:100%}.fit-content-height{height:fit-content;max-height:100%}
:where(p,h1,h2,h3,h4,h5,h6,li){overflow-wrap:anywhere;word-break:normal}img,video,canvas,svg{max-width:100%;height:auto}
.table-wrap{max-width:100%;overflow-x:auto}table.table-fit{width:max-content;max-width:100%;border-collapse:collapse}table.table-fit th,table.table-fit td{padding:.5rem .75rem;text-align:left}
input,select,textarea,button{max-width:100%}
/* Navbar */
.navbar{box-shadow:0 2px 5px rgba(0,0,0,.1);border-bottom:1px solid var(--c-border);background:linear-gradient(90deg,#111827,#142033)}.navbar-brand{font-weight:600;font-size:1.3rem}
/* High contrast on dark navbar in all modes */
.navbar-dark .navbar-brand{color:#f3f4f6}
.navbar-dark .nav-link{color:#e5e7eb}
.navbar-dark .nav-link:hover{color:#ffffff}
.admin-nav-section{margin-left:auto;display:flex;gap:15px}
.admin-nav-section .nav-item{background:var(--c-success);color:#fff;padding:5px 10px;border-radius:6px;text-decoration:none;transition:var(--transition);font-weight:600;font-size:.85rem}
.admin-nav-section .nav-item:hover{background:var(--c-success-hover);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.18)}
.navbar-nav{align-items:center}.navbar-nav .nav-item{margin-right:5px}.navbar-nav .nav-item:last-child{margin-right:0}
/* Flash messages */
.flashes{margin:20px 0}.flash{padding:12px 20px;margin-bottom:15px;border-radius:8px;font-weight:500;border-left:6px solid transparent;background:var(--c-surface);border:1px solid var(--c-border)}
.flash.success{background:rgba(16,185,129,0.12);border-left-color:var(--c-success);color:var(--c-success)}
.flash.error{background:rgba(239,68,68,0.15);border-left-color:var(--c-danger);color:var(--c-danger)}
.flash.info{background:rgba(59,130,246,0.12);border-left-color:var(--c-accent);color:var(--c-accent)}
/* Dropdown */
.dropdown-menu{box-shadow:0 8px 24px rgba(0,0,0,.15);border:1px solid var(--c-border);border-radius:10px;padding:8px 0;min-width:200px;margin-top:6px;background:var(--c-surface);color:var(--c-text)}
.dropdown-item{padding:10px 16px;font-size:.9rem;color:var(--c-text-soft);transition:var(--transition)}
.dropdown-item:hover,.dropdown-item:focus{background:var(--c-surface-alt);color:var(--c-text);transform:translateX(2px)}
.dropdown-divider{margin:6px 0;border-color:var(--c-border)}
/* Logout */
.logout-btn{border-radius:999px;padding:.375rem .85rem;font-weight:600;letter-spacing:.015em;line-height:1.25;transition:var(--transition)}
.navbar-dark .logout-btn.btn-outline-danger{color:#ffc9ce;border-color:rgba(220,53,69,.6)}
.navbar-dark .logout-btn.btn-outline-danger:hover{color:#fff;background:rgba(220,53,69,.9);border-color:rgba(220,53,69,1);transform:translateY(-1px);box-shadow:0 6px 16px rgba(220,53,69,.25)}
/* Scrollbars */
*::-webkit-scrollbar{width:12px;height:12px}*::-webkit-scrollbar-track{background:var(--c-scroll-track)}*::-webkit-scrollbar-thumb{background:var(--c-scroll-thumb);border-radius:20px;border:2px solid var(--c-scroll-track)}*::-webkit-scrollbar-thumb:hover{background:var(--c-scroll-thumb-hover)}
/* Links */
a{color:var(--c-link);text-decoration:none}a:hover{color:var(--c-link-hover)}
/* Generic surfaces */
.card,.content,.form-card,.item-card,.upload-card{background:var(--c-surface);border:1px solid var(--c-border);box-shadow:var(--c-shadow-sm);color:var(--c-text);border-radius:var(--radius-md)}
.item-card:hover{border-color:var(--c-border-strong);box-shadow:var(--c-shadow-md)}
.badge{background:var(--c-badge-bg);color:var(--c-badge-text);border:1px solid var(--c-border);font-weight:600;border-radius:var(--radius-sm);padding:.25rem .55rem;font-size:.7rem;letter-spacing:.03em}
/* Buttons */
.btn,.action-button,.run-backup,.update-system,.actions a.download,.actions a.restore,.upload-actions .primary{background:var(--c-accent);color:#fff !important;border:1px solid var(--c-accent-hover);transition:var(--transition);font-weight:600}
.btn:hover,.action-button:hover,.run-backup:hover,.update-system:hover,.actions a.download:hover,.actions a.restore:hover,.upload-actions .primary:hover{background:var(--c-accent-hover)}
.btn-danger,.danger-button{background:var(--c-danger);border-color:var(--c-danger-hover)}.btn-danger:hover,.danger-button:hover{background:var(--c-danger-hover)}
.ghost{background:transparent;border:1px solid var(--c-border);color:var(--c-text)}.ghost:hover{background:var(--c-surface-alt)}
/* Forms */
input,select,textarea{background:var(--c-surface-alt);border:1px solid var(--c-border);color:var(--c-text);border-radius:var(--radius-sm);padding:.45rem .65rem}
input:focus,select:focus,textarea:focus{border-color:var(--c-accent);box-shadow:var(--c-focus-ring);outline:none}
/* Modals */
.item-modal{background:var(--c-overlay)}.modal-content{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);box-shadow:var(--c-shadow-lg)}
.close-modal{color:var(--c-text-faint)}.close-modal:hover{color:var(--c-text)}
/* Status pill */
.status-pill{background:var(--c-surface);border:1px solid var(--c-border);color:var(--c-text-soft);padding:.25rem .6rem;border-radius:999px;font-weight:600}
.status-pill.on{background:rgba(16,185,129,0.15);border-color:var(--c-success);color:var(--c-success)}
.status-pill.off{background:rgba(239,68,68,0.18);border-color:var(--c-danger);color:var(--c-danger)}
/* Focus */
:focus-visible{outline:none;box-shadow:var(--c-focus-ring)}
/* Utility */
.text-info{opacity:.85}
@media (max-width:768px){.navbar-nav .dropdown-menu{border:none;box-shadow:none;background:rgba(17,24,39,.92);margin-left:15px;margin-top:5px;border-radius:10px}.navbar-nav .dropdown-item{color:#fff}.navbar-nav .dropdown-item:hover,.navbar-nav .dropdown-item:focus{background:rgba(255,255,255,.08);color:#fff;transform:none}.nav-item.dropdown .dropdown-toggle{padding:8px 12px}.dropdown-toggle::after{margin-left:8px}.dropdown-menu-end{background:var(--c-surface);border:1px solid var(--c-border)}.dropdown-menu-end .dropdown-item{color:var(--c-text-soft)}.dropdown-menu-end .dropdown-item:hover{background:var(--c-surface-alt);color:var(--c-text)}}
</style>
{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('default') }}">Inventarsystem Lizenz Verwaltung</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('default') }}">Home</a>
</li>
</ul>
<div class="d-flex ms-auto">
{% if 'username' in session %}
<a id="logout" class="btn btn-outline-danger btn-sm logout-btn" href="{{ url_for('logout') }}" aria-label="Logout">Logout</a>
{% endif %}
</div>
</div>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
</body>
</html>
@@ -1,129 +0,0 @@
<!--
Copyright 2025 Maximilian Gründinger
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center">Login</h2>
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label for="password" class="form-label">TOTP</label>
<div class="password-input-container">
<input type="password" class="form-control" id="password" name="password" required>
<button type="button" class="password-toggle-btn" id="togglePassword" aria-label="Show password">
<input type="checkbox" checked="checked">
<svg class="eye" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 576 512"><path d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"></path></svg>
<svg class="eye-slash" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"></path></svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePassword = document.getElementById('togglePassword');
const passwordInput = document.getElementById('password');
const passwordContainer = document.querySelector('.password-input-container');
togglePassword.addEventListener('click', function() {
// Toggle the password field type
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
// Toggle the visual state of the container
passwordContainer.classList.toggle('password-visible');
//Toggle visability of eye according to state
// Update aria-label for accessibility
const isVisible = type === 'text';
togglePassword.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
});
});
</script>
<style>
.password-input-container {
display: flex;
gap: 10px;
}
#togglePassword {
border-radius: 5px;
}
#togglePassword {
--color: #a5a5b0;
--size: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
font-size: var(--size);
user-select: none;
fill: var(--color);
width: 15%;
}
#togglePassword .eye-slash {
position: absolute;
animation: keyframes-fill .5s;
display: none;
}
#togglePassword .eye {
position: absolute;
animation: keyframes-fill .5s;
}
/* ------ On check event ------ */
#togglePassword input:checked ~ .eye-slash {
display: block;
}
#togglePassword input:checked ~ .eye {
display: none;
}
/* ------ Hide the default checkbox ------ */
#togglePassword input {
position: absolute;
z-index: 100;
opacity: 0;
cursor: pointer;
height: 100%;
width: 100%;
}
/* ------ Animation ------ */
@keyframes keyframes-fill {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
}
</style>
{% endblock %}
@@ -1,85 +0,0 @@
{% extends "base.html" %}
{% block title %}Keys & HWIDs{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h2 class="mb-1">All Keys</h2>
<p class="text-muted mb-0">Overview of license keys and bound HWIDs.</p>
</div>
<div class="d-flex gap-2">
<form method="POST" action="{{ url_for('generate_new') }}" style="display: inline;">
<button type="submit" class="btn btn-success">Generate New Key</button>
</form>
<a href="{{ url_for('download_backup') }}" class="btn btn-info">Download Backup</a>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload Backup</button>
</div>
</div>
<div class="table-wrap">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th scope="col">User ID</th>
<th scope="col">License Key</th>
<th scope="col">HWID</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for item in keys %}
<tr>
<td>{{ item.user_id }}</td>
<td><code>{{ item.license_key }}</code></td>
<td>
{% if item.hwid_uuid %}
<code>{{ item.hwid_uuid }}</code>
{% else %}
<span class="text-warning">Not assigned</span>
{% endif %}
</td>
<td class="text-end">
<form method="POST" action="{{ url_for('remove_key', user_id=item.user_id) }}" onsubmit="return confirm('Remove key for user {{ item.user_id }}?');">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No keys found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Upload Backup Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uploadModalLabel">Upload Licenses Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('upload_backup') }}" enctype="multipart/form-data">
<div class="modal-body">
<div class="mb-3">
<label for="backupFile" class="form-label">Select JSON backup file</label>
<input class="form-control" type="file" id="backupFile" name="file" accept=".json" required>
<small class="text-muted">Select a previously downloaded licenses_backup.json file</small>
</div>
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Uploading a backup will replace all current licenses. This action cannot be undone.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to restore from this backup? Current licenses will be replaced.');">Restore</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
-11
View File
@@ -1,11 +0,0 @@
curl -i -X POST "http://127.0.0.1:5000/validate__information" \
-H "Content-Type: application/json" \
-d '{"license":"npEDI1JVhyisQ2dg-fWTTAn5H1hg3eS80Zc-4IptWIU=","hwid":""}'
curl -i -X POST "http://127.0.0.1:5000/validate__information" \
-H "Content-Type: application/json" \
-d '{"license":"npEDI1JVhyisQ2dg-fWTTAn5H1hg3eS80Zc-4IptWIU=","hwid":"0c27aefd7107396933c7363efa38fb6c297aa3db119c1597952c05d947f4f27c"}'
curl -i -X POST "http://127.0.0.1:5000/validate__information" \
-H "Content-Type: application/json" \
-d '{"license":"npEDI1JVhyisQ2dg-fWTTAn5H1hg3eS80Zc-4IptWIU=","hwid":"0c27aefd7107396933c7363efa38fb6c297aa3db119c1597952c05d947f4f37c"}'
@@ -1,61 +0,0 @@
import json
import os
import secrets
LICENSES_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__), "licenses.json"))
def load_file() -> dict:
with open(LICENSES_FILE, "r") as f:
data = json.load(f)
return data
def check(license_key, hwid_uuid) -> bool:
data = load_file()
for i in data:
if str(i["license_key"]) == str(license_key):
if str(i["hwid_uuid"]) == str(hwid_uuid):
return True
elif str(i["hwid_uuid"]) == "":
i["hwid_uuid"] = str(hwid_uuid)
register_new_Key(data)
return True
return False
def register_new_Key(data_stream):
with open(LICENSES_FILE, "w") as f:
json.dump(data_stream, f, indent=2)
def new_key() -> str:
data = load_file()
existing_ids = []
for item in data:
user_id = str(item.get("user_id", "")).strip()
if user_id.isdigit():
existing_ids.append(int(user_id))
next_id = (max(existing_ids) + 1) if existing_ids else 1
generated_key = key_generator()
data.append(
{
"user_id": f"{next_id:04d}",
"license_key": generated_key,
"hwid_uuid": "",
}
)
register_new_Key(data)
return generated_key
def remove_key(user_id: str) -> bool:
data = load_file()
filtered = [item for item in data if str(item.get("user_id", "")) != str(user_id)]
if len(filtered) == len(data):
return False
register_new_Key(filtered)
return True
def key_generator():
return secrets.token_urlsafe(32)
@@ -1 +0,0 @@
0.1.0
+94 -1
View File
@@ -1 +1,94 @@
# Inventarsystem Lizenz Verwaltung
# Key Service Server
Dieses Repo nutzt zwei zentrale Shell-Skripte:
- `gitea.sh`: Start/Stop/Status der Docker-Services (Gitea + Website)
- `nginx.sh`: Nginx Reverse Proxy, SSL-Zertifikate und Aktivierung/Deaktivierung der Sites
## Voraussetzungen
- Docker + Docker Compose Plugin
- Nginx
- OpenSSL
- Optional: UFW (wird automatisch fuer 80/443 konfiguriert, wenn aktiv)
## 1) Docker Services (`gitea.sh`)
Datei: [gitea.sh](gitea.sh)
### Befehle
- `sudo ./gitea.sh start`
- Erstellt/aktualisiert Gitea Compose unter `/opt/gitea-server/docker-compose.yml`
- Startet Gitea und Website Docker Services
- Schreibt Runtime-Werte nach `/opt/gitea-server/runtime.env` fuer Nginx
- `sudo ./gitea.sh stop`
- Stoppt Gitea und Website Docker Services
- `sudo ./gitea.sh restart`
- Entspricht `stop` gefolgt von `start`
- `sudo ./gitea.sh status`
- Zeigt laufende Container fuer Gitea/Website an
### Wichtige Ports
- Gitea intern: `127.0.0.1:3001` (fix)
- Gitea SSH: `2222`
- Website Upstream: `4999`
## 2) Nginx (`nginx.sh`)
Datei: [nginx.sh](nginx.sh)
### Befehle
- `sudo ./nginx.sh apply`
- Liest Runtime-Werte aus `/opt/gitea-server/runtime.env`
- Erstellt Nginx Site-Konfigurationen fuer:
- `update.meine-domain` -> Gitea
- `meine-domain` -> Website
- Prueft Zertifikate im Ordner `/etc/nginx/certs`
- Falls Zertifikate fehlen: erzeugt self-signed Zertifikate automatisch
- Testet Nginx-Konfiguration und laedt Nginx neu
- `sudo ./nginx.sh disable`
- `sudo ./nginx.sh deactivate`
- Deaktiviert die verwalteten Nginx Sites (`gitea`, `website`)
- Testet und laedt Nginx neu
## Typischer Ablauf
1. Docker Services starten:
```bash
sudo ./gitea.sh start
```
2. Nginx konfigurieren/aktivieren:
```bash
sudo ./nginx.sh apply
```
3. Status pruefen:
```bash
sudo ./gitea.sh status
```
4. Nginx bei Bedarf deaktivieren:
```bash
sudo ./nginx.sh disable
```
## Nginx Templates
Die Nginx-Vorlagen liegen unter [nginx](nginx):
- [nginx/gitea.http.conf.template](nginx/gitea.http.conf.template)
- [nginx/gitea.https.conf.template](nginx/gitea.https.conf.template)
- [nginx/website.http.conf.template](nginx/website.http.conf.template)
- [nginx/website.https.conf.template](nginx/website.https.conf.template)
Executable
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
# Konfiguration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GITEA_DIR="/opt/gitea-server"
WEBSITE_DIR="$SCRIPT_DIR/Website"
GITEA_DOMAIN="update.meine-domain"
GITEA_HTTP_PORT=3001
GITEA_SSH_PORT=2222
GITEA_BIND_IP="127.0.0.1"
WEBSITE_DOMAIN="meine-domain"
WEBSITE_UPSTREAM_PORT=4999
RUNTIME_ENV_FILE="$GITEA_DIR/runtime.env"
ACTION="${1:-start}"
usage() {
echo "Nutzung: $0 [start|stop|restart|status]"
}
current_host_info() {
local host_name
local host_ip
host_name="$(hostname)"
host_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [ -z "$host_ip" ]; then
host_ip="unbekannt"
fi
echo "$host_name ($host_ip)"
}
write_gitea_compose() {
local gitea_http_port="$1"
echo "Erstelle docker-compose.yml fuer Gitea..."
cat <<EOF > "$GITEA_DIR/docker-compose.yml"
networks:
gitea:
external: false
services:
server:
image: gitea/gitea:1.21
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__DOMAIN=$GITEA_DOMAIN
- GITEA__server__SSH_DOMAIN=$GITEA_DOMAIN
- GITEA__server__HTTP_PORT=3001
- GITEA__server__ROOT_URL=https://$GITEA_DOMAIN
- GITEA__server__SSH_PORT=$GITEA_SSH_PORT
restart: always
networks:
- gitea
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "$GITEA_BIND_IP:$gitea_http_port:3001"
- "$GITEA_SSH_PORT:22"
EOF
}
write_runtime_config() {
local gitea_http_port="$1"
# Runtime-Konfiguration fuer getrenntes nginx Setup speichern.
sudo tee "$RUNTIME_ENV_FILE" >/dev/null <<EOF
GITEA_DOMAIN=$GITEA_DOMAIN
GITEA_HTTP_PORT=$gitea_http_port
WEBSITE_DOMAIN=$WEBSITE_DOMAIN
WEBSITE_UPSTREAM_PORT=$WEBSITE_UPSTREAM_PORT
EOF
}
start_website_service() {
if [ ! -d "$WEBSITE_DIR" ]; then
echo "Fehler: Website Verzeichnis nicht gefunden: $WEBSITE_DIR"
exit 1
fi
echo "Starte Website Docker Services..."
cd "$WEBSITE_DIR"
sudo docker compose up -d
}
start_gitea_service() {
echo "Starte Gitea Docker Service..."
cd "$GITEA_DIR"
sudo docker compose up -d
}
stop_services() {
echo "Stoppe Docker Services (Gitea + Website)..."
if [ -f "$GITEA_DIR/docker-compose.yml" ]; then
cd "$GITEA_DIR"
sudo docker compose down
elif sudo docker ps -a --format '{{.Names}}' | grep -qx gitea; then
sudo docker stop gitea >/dev/null
sudo docker rm gitea >/dev/null
fi
if [ -f "$WEBSITE_DIR/docker-compose.yml" ]; then
cd "$WEBSITE_DIR"
sudo docker compose down || true
fi
echo "-------------------------------------------------------"
echo "Services wurden gestoppt."
echo "Hinweis: nginx Proxy-Konfiguration bleibt aktiv und getrennt verwaltet."
echo "-------------------------------------------------------"
}
start_services() {
echo "Erstelle Gitea-Verzeichnisse in $GITEA_DIR..."
sudo mkdir -p "$GITEA_DIR/data" "$GITEA_DIR/config"
write_gitea_compose "$GITEA_HTTP_PORT"
write_runtime_config "$GITEA_HTTP_PORT"
start_gitea_service
start_website_service
echo "-------------------------------------------------------"
echo "Docker Services wurden gestartet!"
echo "Gitea Intern: $GITEA_BIND_IP:$GITEA_HTTP_PORT"
echo "Gitea SSH: $GITEA_SSH_PORT"
echo "Website Port: $WEBSITE_UPSTREAM_PORT"
echo "Gitea Domain: $GITEA_DOMAIN"
echo "Web Domain: $WEBSITE_DOMAIN"
echo "Aktueller Host: $(current_host_info)"
echo ""
echo "Nginx wird jetzt ueber ein separates Skript verwaltet."
echo "Fuehre danach aus: sudo ./nginx.sh apply"
echo "-------------------------------------------------------"
}
show_status() {
echo "Docker Status (gitea / website):"
sudo docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'NAMES|gitea|website-website-1|website-mongodb-1' || true
}
case "$ACTION" in
start)
start_services
;;
stop)
stop_services
;;
restart)
stop_services
start_services
;;
status)
show_status
;;
*)
usage
exit 1
;;
esac
Executable
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
# Konfiguration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NGINX_TEMPLATE_DIR="$SCRIPT_DIR/nginx"
GITEA_DIR="/opt/gitea-server"
RUNTIME_ENV_FILE="$GITEA_DIR/runtime.env"
CERT_DIR="/etc/nginx/certs"
SSL_CERT_DAYS="825"
# Fallback-Werte (werden durch runtime.env ueberschrieben, wenn vorhanden)
GITEA_DOMAIN="update.meine-domain"
GITEA_HTTP_PORT="3001"
WEBSITE_DOMAIN="meine-domain"
WEBSITE_UPSTREAM_PORT="4999"
ACTION="${1:-apply}"
usage() {
echo "Nutzung: $0 [apply|disable|deactivate]"
echo "Hinweis: Docker Services mit ./gitea.sh [start|stop|restart|status] verwalten."
}
current_host_info() {
local host_name
local host_ip
host_name="$(hostname)"
host_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [ -z "$host_ip" ]; then
host_ip="unbekannt"
fi
echo "$host_name ($host_ip)"
}
load_runtime_config() {
if [ -f "$RUNTIME_ENV_FILE" ]; then
# Nur erwartete Keys werden geladen.
while IFS='=' read -r key value; do
case "$key" in
GITEA_DOMAIN) GITEA_DOMAIN="$value" ;;
GITEA_HTTP_PORT) GITEA_HTTP_PORT="$value" ;;
WEBSITE_DOMAIN) WEBSITE_DOMAIN="$value" ;;
WEBSITE_UPSTREAM_PORT) WEBSITE_UPSTREAM_PORT="$value" ;;
esac
done < "$RUNTIME_ENV_FILE"
else
echo "Warnung: $RUNTIME_ENV_FILE nicht gefunden, nutze Standardwerte."
fi
}
render_nginx_template() {
local template_file="$1"
local output_file="$2"
local domain="$3"
local upstream_port="$4"
local cert_file="$5"
local key_file="$6"
sed \
-e "s|__DOMAIN__|$domain|g" \
-e "s|__PORT__|$upstream_port|g" \
-e "s|__CERT_FILE__|$cert_file|g" \
-e "s|__KEY_FILE__|$key_file|g" \
"$template_file" | sudo tee "$output_file" >/dev/null
}
configure_nginx_site() {
local site_name="$1"
local domain="$2"
local upstream_port="$3"
local cert_file="$CERT_DIR/$domain.crt"
local key_file="$CERT_DIR/$domain.key"
local template_file="$NGINX_TEMPLATE_DIR/$site_name.https.conf.template"
local site_file="/etc/nginx/sites-available/$site_name"
local site_link="/etc/nginx/sites-enabled/$site_name"
ensure_ssl_certificate "$domain" "$cert_file" "$key_file"
echo "Nutze HTTPS nginx Template fuer $domain."
if [ ! -f "$template_file" ]; then
echo "Fehler: nginx Template nicht gefunden: $template_file"
exit 1
fi
render_nginx_template "$template_file" "$site_file" "$domain" "$upstream_port" "$cert_file" "$key_file"
sudo ln -sf "$site_file" "$site_link"
}
ensure_ssl_certificate() {
local domain="$1"
local cert_file="$2"
local key_file="$3"
if ! command -v openssl >/dev/null 2>&1; then
echo "Fehler: openssl ist nicht installiert."
exit 1
fi
sudo mkdir -p "$CERT_DIR"
if [ -f "$cert_file" ] && [ -f "$key_file" ]; then
echo "SSL Zertifikat gefunden fuer $domain: $cert_file"
return
fi
echo "Kein SSL Zertifikat fuer $domain gefunden. Erzeuge self-signed Zertifikat..."
if ! sudo openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days "$SSL_CERT_DAYS" \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$domain" \
-addext "subjectAltName=DNS:$domain"; then
# Fallback fuer aeltere openssl-Versionen ohne -addext.
sudo openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days "$SSL_CERT_DAYS" \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$domain"
fi
sudo chmod 600 "$key_file"
sudo chmod 644 "$cert_file"
}
reload_nginx() {
echo "Pruefe nginx Konfiguration..."
sudo nginx -t
if sudo pgrep -x nginx >/dev/null 2>&1; then
echo "Nginx Prozess gefunden, lade Konfiguration neu..."
if ! sudo nginx -s reload; then
echo "Warnung: nginx konnte nicht automatisch neu geladen werden."
echo "Die Konfiguration wurde geschrieben; bitte nginx bei Bedarf manuell neu laden."
fi
elif sudo systemctl is-active --quiet nginx; then
echo "Lade nginx neu..."
if ! sudo systemctl reload nginx; then
echo "Warnung: nginx reload via systemd fehlgeschlagen."
echo "Bitte pruefe: sudo systemctl status nginx.service"
fi
else
echo "Nginx ist nicht aktiv, starte nginx..."
if ! sudo systemctl start nginx; then
echo "Warnung: Nginx konnte nicht ueber systemd gestartet werden."
echo "Falls nginx bereits manuell laeuft, kann die Weiterleitung trotzdem funktionieren."
echo "Ansonsten pruefe: sudo systemctl status nginx.service"
echo "Und Logs mit: sudo journalctl -xeu nginx.service"
fi
fi
}
ensure_firewall_http_https() {
if ! command -v ufw >/dev/null 2>&1; then
return
fi
if sudo ufw status 2>/dev/null | grep -qi "Status: active"; then
echo "Oeffne Firewall fuer 80/tcp und 443/tcp (ufw)..."
sudo ufw allow 80/tcp >/dev/null || true
sudo ufw allow 443/tcp >/dev/null || true
fi
}
apply_nginx() {
if ! command -v nginx >/dev/null 2>&1; then
echo "Fehler: nginx ist nicht installiert. Bitte zuerst nginx installieren."
exit 1
fi
load_runtime_config
echo "Erstelle nginx Reverse-Proxy Konfigurationen..."
configure_nginx_site "gitea" "$GITEA_DOMAIN" "$GITEA_HTTP_PORT"
configure_nginx_site "website" "$WEBSITE_DOMAIN" "$WEBSITE_UPSTREAM_PORT"
ensure_firewall_http_https
reload_nginx
echo "-------------------------------------------------------"
echo "Nginx wurde aktualisiert."
echo "Gitea Domain: $GITEA_DOMAIN -> 127.0.0.1:$GITEA_HTTP_PORT"
echo "Web Domain: $WEBSITE_DOMAIN -> 127.0.0.1:$WEBSITE_UPSTREAM_PORT"
echo "Zertifikat Ordner: $CERT_DIR"
echo "Aktueller Host: $(current_host_info)"
echo "-------------------------------------------------------"
}
disable_nginx() {
local changed=0
if ! command -v nginx >/dev/null 2>&1; then
echo "Fehler: nginx ist nicht installiert."
exit 1
fi
for site_name in gitea website; do
local site_link="/etc/nginx/sites-enabled/$site_name"
if [ -L "$site_link" ] || [ -e "$site_link" ]; then
echo "Deaktiviere nginx Site: $site_name"
sudo rm -f "$site_link"
changed=1
fi
done
if [ "$changed" -eq 1 ]; then
reload_nginx
echo "-------------------------------------------------------"
echo "Nginx Reverse-Proxy Sites wurden deaktiviert."
echo "Aktueller Host: $(current_host_info)"
echo "-------------------------------------------------------"
else
echo "Keine verwalteten nginx Sites aktiv (gitea/website)."
fi
}
case "$ACTION" in
apply)
apply_nginx
;;
disable|deactivate)
disable_nginx
;;
*)
usage
exit 1
;;
esac
+13
View File
@@ -0,0 +1,13 @@
server {
listen 80;
server_name __DOMAIN__;
location / {
proxy_pass http://127.0.0.1:__PORT__;
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_http_version 1.1;
}
}
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name __DOMAIN__;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name __DOMAIN__;
ssl_certificate __CERT_FILE__;
ssl_certificate_key __KEY_FILE__;
location / {
proxy_pass http://127.0.0.1:__PORT__;
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_http_version 1.1;
}
}
+13
View File
@@ -0,0 +1,13 @@
server {
listen 80 default_server;
server_name __DOMAIN__ _;
location / {
proxy_pass http://127.0.0.1:__PORT__;
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_http_version 1.1;
}
}
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80 default_server;
server_name __DOMAIN__ _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
server_name __DOMAIN__ _;
ssl_certificate __CERT_FILE__;
ssl_certificate_key __KEY_FILE__;
location / {
proxy_pass http://127.0.0.1:__PORT__;
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_http_version 1.1;
}
}