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:
@@ -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": ""
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user