Compare commits

...

4 Commits

9 changed files with 444 additions and 118 deletions
+138 -77
View File
@@ -27,6 +27,7 @@ Features:
from flask import Flask, render_template, request, redirect, url_for, session, flash, send_from_directory, get_flashed_messages, jsonify, Response, make_response, send_file
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
import user as us
import items as it
import ausleihung as au
@@ -44,6 +45,7 @@ import traceback
import re
import io
import html
import logging
# QR Code functionality deactivated
# import qrcode
# from qrcode.constants import ERROR_CORRECT_L
@@ -54,6 +56,11 @@ import uuid
from PIL import Image, ImageOps
import mimetypes
import subprocess
from data_protection import (
decrypt_document_fields,
encrypt_document_fields,
encrypt_soft_deleted_media_pack,
)
# Set base directory and centralized settings
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -62,13 +69,43 @@ from settings import MongoClient
app = Flask(__name__, static_folder='static') # Correctly set static folder
app.logger.setLevel(logging.WARNING)
app.secret_key = cfg.SECRET_KEY
app.debug = cfg.DEBUG
app.config['UPLOAD_FOLDER'] = cfg.UPLOAD_FOLDER
app.config['THUMBNAIL_FOLDER'] = cfg.THUMBNAIL_FOLDER
app.config['PREVIEW_FOLDER'] = cfg.PREVIEW_FOLDER
app.config['ALLOWED_EXTENSIONS'] = set(cfg.ALLOWED_EXTENSIONS)
app.config['MAX_CONTENT_LENGTH'] = max(cfg.MAX_UPLOAD_MB, cfg.IMAGE_MAX_UPLOAD_MB, cfg.VIDEO_MAX_UPLOAD_MB) * 1024 * 1024
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SESSION_COOKIE_SECURE') is None else os.getenv('INVENTAR_SESSION_COOKIE_SECURE', '').strip().lower() in ('1', 'true', 'yes', 'on')
app.config['PREFERRED_URL_SCHEME'] = 'https' if app.config['SESSION_COOKIE_SECURE'] else 'http'
# app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
def print(*args, **kwargs):
if not args:
return None
message = " ".join(str(arg) for arg in args)
stripped = message.lstrip()
if stripped.startswith(('Error', 'Fehler', 'Warning', 'Warnung', '[WARN]', '[KONFLIKT]', 'Failed', 'Fehl')):
app.logger.warning(message)
elif stripped.startswith(('Exception', 'Traceback')):
app.logger.error(message)
else:
app.logger.info(message)
STUDENT_CARD_ENCRYPTED_FIELDS = ('SchülerName', 'Klasse', 'Notizen')
def _decrypt_student_card_doc(card_doc):
if not card_doc:
return card_doc
return decrypt_document_fields(card_doc, STUDENT_CARD_ENCRYPTED_FIELDS)
# Thumbnail sizes
THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE
@@ -96,6 +133,16 @@ APP_VERSION = __version__
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
@app.after_request
def _set_security_headers(response):
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
if cfg.SSL_ENABLED:
response.headers.setdefault('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
return response
def _get_asset_version():
"""Return a cache-busting asset version tied to deployment state."""
env_version = os.getenv('INVENTAR_ASSET_VERSION', '').strip()
@@ -653,14 +700,11 @@ def create_daily_backup():
Erstellt täglich ein Backup der Ausleihungsdatenbank
"""
try:
print(f"[{datetime.datetime.now()}] Erstelle Backup der Ausleihungsdatenbank...")
result = au.create_backup_database()
if result:
print(f"[{datetime.datetime.now()}] Backup erfolgreich erstellt")
else:
print(f"[{datetime.datetime.now()}] Fehler beim Erstellen des Backups")
if not result:
app.logger.warning("Daily backup creation returned false")
except Exception as e:
print(f"[{datetime.datetime.now()}] Ausnahme beim Erstellen des Backups: {str(e)}")
app.logger.error(f"Daily backup creation failed: {e}")
def update_appointment_statuses():
"""
@@ -670,17 +714,8 @@ def update_appointment_statuses():
- Aktive Termine, die beendet werden sollten
"""
current_time = datetime.datetime.now()
# Prepare logging early so it's available in exception paths
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, 'scheduler.log')
try:
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"[{current_time}] Starte automatische Statusaktualisierung...\n")
print(f"[{current_time}] Starte automatische Statusaktualisierung...")
# Hole alle Termine mit Status 'planned' oder 'active'
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
@@ -742,16 +777,15 @@ def update_appointment_statuses():
f" [KONFLIKT] Termin {appointment['_id']}: "
f"planned → active, aber {conflict_note}"
)
print(conflict_log)
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"[{current_time}] {conflict_log}\n")
app.logger.warning(conflict_log)
else:
# No conflict — clear any previously stored conflict flag
extra_fields['ConflictDetected'] = False
extra_fields['ConflictNote'] = ''
except Exception as conflict_err:
print(f" [WARN] Konfliktprüfung fehlgeschlagen für Termin "
f"{appointment['_id']}: {conflict_err}")
app.logger.warning(
f"Conflict check failed for appointment {appointment['_id']}: {conflict_err}"
)
result = ausleihungen.update_one(
{'_id': appointment['_id']},
@@ -769,37 +803,15 @@ def update_appointment_statuses():
elif new_status == 'completed':
completed_count += 1
log_msg = f" - Termin {appointment['_id']}: {old_status}{new_status}"
print(log_msg)
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"[{current_time}] {log_msg}\n")
client.close()
if updated_count > 0:
result_msg = f"Statusaktualisierung abgeschlossen: {updated_count} Termine aktualisiert"
detail_msg = f" - {activated_count} aktiviert, {completed_count} abgeschlossen"
print(f"[{current_time}] {result_msg}")
print(f"[{current_time}] {detail_msg}")
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"[{current_time}] {result_msg}\n")
f.write(f"[{current_time}] {detail_msg}\n")
else:
result_msg = "Statusaktualisierung abgeschlossen: Keine Änderungen erforderlich"
print(f"[{current_time}] {result_msg}")
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"[{current_time}] {result_msg}\n")
app.logger.warning(
f"Appointment status update finished: {updated_count} changed ({activated_count} active, {completed_count} completed)"
)
except Exception as e:
error_msg = f"Fehler bei der automatischen Statusaktualisierung: {str(e)}"
print(f"[{datetime.datetime.now()}] {error_msg}")
try:
with open(log_file, 'a', encoding='utf-8') as f:
f.write(f"[{datetime.datetime.now()}] {error_msg}\n")
except Exception:
pass
import traceback
traceback.print_exc()
app.logger.error(f"Automatic appointment status update failed: {e}")
# Schedule jobs
scheduler = BackgroundScheduler()
@@ -1484,7 +1496,7 @@ def uploaded_file(filename):
# Default placeholder from static folder
return send_from_directory(app.static_folder, 'favicon.ico')
except Exception as e:
print(f"Error serving file {filename}: {str(e)}")
app.logger.error(f"Error serving file {filename}: {str(e)}")
return Response("Image not found", status=404)
@@ -1519,7 +1531,7 @@ def thumbnail_file(filename):
else:
return send_from_directory(app.static_folder, 'favicon.ico')
except Exception as e:
print(f"Error serving thumbnail {filename}: {str(e)}")
app.logger.error(f"Error serving thumbnail {filename}: {str(e)}")
return Response("Thumbnail not found", status=404)
@@ -1554,7 +1566,7 @@ def preview_file(filename):
else:
return send_from_directory(app.static_folder, 'favicon.ico')
except Exception as e:
print(f"Error serving preview {filename}: {str(e)}")
app.logger.error(f"Error serving preview {filename}: {str(e)}")
return Response("Preview not found", status=404)
@@ -2017,7 +2029,7 @@ def api_library_items():
'has_more': (offset + count) < total_count
}), 200
except Exception as e:
print(f"Error fetching library items: {e}")
app.logger.error(f"Error fetching library items: {e}")
return jsonify({'error': str(e)}), 500
@@ -2060,6 +2072,7 @@ def api_library_scan_action():
card_doc = student_cards_col.find_one({'AusweisId': student_card_id})
if not card_doc:
return jsonify({'ok': False, 'message': 'Ungültige Schülerausweis-ID.'}), 404
card_doc = _decrypt_student_card_doc(card_doc)
query_or = [
{'Code_4': item_code_raw},
@@ -2227,7 +2240,7 @@ def api_item_detail(item_id):
"""
return detail_html, 200
except Exception as e:
print(f"Error fetching item detail: {e}")
app.logger.error(f"Error fetching item detail: {e}")
return jsonify({'error': str(e)}), 500
@@ -2330,11 +2343,6 @@ def upload_admin():
try:
original_item = it.get_item(duplicate_from)
if original_item:
# Enhanced debug logging for images
images = original_item.get('Images', [])
print(f"DEBUG: Original item: {original_item.get('_id')} has these images: {images}")
print(f"DEBUG: Images type: {type(images)}, count: {len(images) if isinstance(images, list) else 'not a list'}")
duplicate_data = {
'name': original_item.get('Name', ''),
'description': original_item.get('Beschreibung', ''),
@@ -2360,7 +2368,7 @@ def upload_admin():
else:
flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error')
except Exception as e:
print(f"Error loading item for duplication: {e}")
app.logger.warning(f"Error loading item for duplication: {e}")
flash('Fehler beim Laden der Duplizierungsdaten.', 'error')
# Handle the new method (sessionStorage-based duplication)
@@ -2429,7 +2437,7 @@ def library_admin():
else:
flash('Ursprungs-Element für Duplizierung nicht gefunden.', 'error')
except Exception as e:
print(f"Error loading item for duplication: {e}")
app.logger.warning(f"Error loading item for duplication: {e}")
flash('Fehler beim Laden der Duplizierungsdaten.', 'error')
elif duplicate_flag == 'true':
flash('Buch wird dupliziert. Die Daten werden aus dem Session-Speicher geladen.', 'info')
@@ -2476,6 +2484,7 @@ def student_cards_admin():
try:
card = student_cards.find_one({'_id': ObjectId(edit_id)})
if card:
card = _decrypt_student_card_doc(card)
edit_mode = True
form_data = {
'card_id': str(card['_id']),
@@ -2486,7 +2495,7 @@ def student_cards_admin():
'notes': card.get('Notizen', '')
}
except Exception as e:
print(f"Error loading student card for edit: {e}")
app.logger.error(f"Error loading student card for edit: {e}")
flash('Fehler beim Laden des Ausweises.', 'error')
# Handle POST request (add or edit)
@@ -2504,7 +2513,7 @@ def student_cards_admin():
student_cards.delete_one({'_id': ObjectId(card_id)})
flash('Ausweis wurde gelöscht.', 'success')
except Exception as e:
print(f"Error deleting student card: {e}")
app.logger.error(f"Error deleting student card: {e}")
flash('Fehler beim Löschen des Ausweises.', 'error')
elif action == 'edit':
@@ -2518,21 +2527,27 @@ def student_cards_admin():
if existing:
flash('Diese Ausweis-ID existiert bereits.', 'error')
else:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': student_name,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.update_one(
{'_id': ObjectId(card_id)},
{'$set': {
'AusweisId': ausweis_id,
'SchülerName': student_name,
'StandardAusleihdauer': int(default_borrow_days),
'Klasse': class_name,
'Notizen': notes,
'Aktualisiert': datetime.datetime.now()
'Aktualisiert': datetime.datetime.now(),
**encrypted_payload
}}
)
flash('Ausweis wurde aktualisiert.', 'success')
return redirect(url_for('student_cards_admin'))
except Exception as e:
print(f"Error updating student card: {e}")
app.logger.error(f"Error updating student card: {e}")
flash('Fehler beim Aktualisieren des Ausweises.', 'error')
elif action == 'add':
@@ -2545,22 +2560,29 @@ def student_cards_admin():
flash('Diese Ausweis-ID existiert bereits.', 'error')
else:
try:
encrypted_payload = encrypt_document_fields(
{
'SchülerName': student_name,
'Klasse': class_name,
'Notizen': notes,
},
STUDENT_CARD_ENCRYPTED_FIELDS
)
student_cards.insert_one({
'AusweisId': ausweis_id,
'SchülerName': student_name,
'StandardAusleihdauer': int(default_borrow_days),
'Klasse': class_name,
'Notizen': notes,
'Erstellt': datetime.datetime.now()
'Erstellt': datetime.datetime.now(),
**encrypted_payload,
})
flash('Neuer Ausweis wurde hinzugefügt.', 'success')
return redirect(url_for('student_cards_admin'))
except Exception as e:
print(f"Error adding student card: {e}")
app.logger.error(f"Error adding student card: {e}")
flash('Fehler beim Hinzufügen des Ausweises.', 'error')
# Get all student cards
all_cards = list(student_cards.find().sort('AusweisId', 1))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
client.close()
return render_template(
@@ -2597,6 +2619,7 @@ def student_cards_print():
# Get all student cards sorted by ID
all_cards = list(student_cards.find().sort('AusweisId', 1))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
client.close()
return render_template(
@@ -2628,6 +2651,7 @@ def student_card_barcode_print():
# Get all student cards sorted by ID
all_cards = list(student_cards.find().sort('AusweisId', 1))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
client.close()
return render_template(
@@ -2667,6 +2691,7 @@ def student_card_barcode_download():
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
all_cards = list(student_cards.find().sort('AusweisId', 1))
all_cards = [_decrypt_student_card_doc(card) for card in all_cards]
client.close()
pdf_buffer = BytesIO()
@@ -2841,6 +2866,7 @@ def student_card_single_barcode_download(card_id):
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
card = student_cards.find_one({'_id': ObjectId(card_id)})
card = _decrypt_student_card_doc(card)
client.close()
if not card:
@@ -4516,8 +4542,28 @@ def _soft_delete_item_groups(db, root_item_ids, username):
'soft_deleted_items': 0,
'soft_deleted_borrows': 0,
'group_item_ids': [],
'archive': {
'archive_created': False,
'archived_files': 0,
'deleted_files': 0,
'archive_path': None,
},
}
object_ids = []
for group_item_id in unique_group_item_ids:
try:
object_ids.append(ObjectId(group_item_id))
except Exception:
continue
item_docs_for_archive = []
if object_ids:
item_docs_for_archive = list(db['items'].find(
{'_id': {'$in': object_ids}},
{'_id': 1, 'Images': 1}
))
delete_success = True
soft_deleted_items = 0
@@ -4550,6 +4596,17 @@ def _soft_delete_item_groups(db, root_item_ids, username):
}}
)
archive_result = {
'archive_created': False,
'archived_files': 0,
'deleted_files': 0,
'archive_path': None,
}
try:
archive_result = encrypt_soft_deleted_media_pack(item_docs_for_archive, actor=username)
except Exception as archive_err:
app.logger.warning(f"Soft-delete media archive failed: {archive_err}")
_append_audit_event(
db,
'inventory_item_soft_deleted',
@@ -4558,6 +4615,7 @@ def _soft_delete_item_groups(db, root_item_ids, username):
'group_item_ids': unique_group_item_ids,
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrow_records': borrow_result.modified_count,
'soft_delete_archive': archive_result,
}
)
@@ -4566,6 +4624,7 @@ def _soft_delete_item_groups(db, root_item_ids, username):
'soft_deleted_items': soft_deleted_items,
'soft_deleted_borrows': borrow_result.modified_count,
'group_item_ids': unique_group_item_ids,
'archive': archive_result,
'message': 'OK' if delete_success else 'Fehler beim revisionssicheren Deaktivieren des Elements.',
}
@@ -4612,15 +4671,20 @@ def delete_item(id):
delete_success = bool(delete_result.get('success'))
soft_deleted_items = int(delete_result.get('soft_deleted_items', 0))
soft_deleted_borrows = int(delete_result.get('soft_deleted_borrows', 0))
archived_files = int((delete_result.get('archive') or {}).get('archived_files', 0))
except Exception as e:
app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}")
delete_success = False
archived_files = 0
finally:
if client:
client.close()
if delete_success:
flash(f'Elementgruppe revisionssicher deaktiviert ({soft_deleted_items}/{len(group_item_ids)} Versionen).', 'success')
flash(
f'Elementgruppe revisionssicher deaktiviert ({soft_deleted_items}/{len(group_item_ids)} Versionen, {archived_files} Mediendateien verschlüsselt archiviert).',
'success'
)
else:
flash('Fehler beim revisionssicheren Deaktivieren des Elements.', 'error')
@@ -4663,6 +4727,8 @@ def bulk_delete_items():
'message': f"{result.get('soft_deleted_items', 0)} Elemente revisionssicher deaktiviert.",
'deleted_items': result.get('soft_deleted_items', 0),
'deleted_borrows': result.get('soft_deleted_borrows', 0),
'archived_files': (result.get('archive') or {}).get('archived_files', 0),
'archive_created': (result.get('archive') or {}).get('archive_created', False),
'group_item_ids': result.get('group_item_ids', []),
})
except Exception as e:
@@ -5196,7 +5262,7 @@ def ausleihen(id):
flash('Alle Exemplare sind aufgrund geplanter Reservierungen heute belegt.', 'error')
return redirect(url_for(redirect_target))
except Exception as e:
print(f"Warning: could not enforce planned booking guard: {e}")
app.logger.warning(f"Could not enforce planned booking guard: {e}")
# Get number of exemplars to borrow (default to 1)
exemplare_count = request.form.get('exemplare_count', 1)
@@ -5317,10 +5383,7 @@ def zurueckgeben(id):
username = session['username']
print("Code 1169: zurueckgeben called with item ID:", id)
if not item.get('Verfuegbar', True) and (us.check_admin(session['username']) or item.get('User') == username):
print("Code 1172: Item is not available, proceeding with return")
try:
# Get ALL active borrowing records for this item and complete them
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
@@ -5339,8 +5402,6 @@ def zurueckgeben(id):
updated_count = 0
for record in active_records:
ausleihung_id = str(record['_id'])
print(f"Completing active ausleihung {ausleihung_id} for item {id}")
# Update each active record
result = ausleihungen.update_one(
{'_id': ObjectId(ausleihung_id)},
+176
View File
@@ -0,0 +1,176 @@
"""Helpers for targeted PII encryption and encrypted archival of deleted media files."""
import base64
import hashlib
import json
import os
import uuid
import zipfile
from datetime import datetime
from cryptography.fernet import Fernet, InvalidToken
import settings as cfg
_ENC_PREFIX = "enc::"
def _resolve_fernet_key():
"""Resolve the Fernet key from env/config or derive a stable fallback."""
configured_key = cfg.DATA_ENCRYPTION_KEY
if configured_key:
try:
# Validate the supplied key format.
Fernet(configured_key.encode("utf-8"))
return configured_key.encode("utf-8")
except Exception:
pass
# Fallback for compatibility: derive stable key from SECRET_KEY.
digest = hashlib.sha256(cfg.SECRET_KEY.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
def _fernet():
return Fernet(_resolve_fernet_key())
def encrypt_text(value):
"""Encrypt a text value. Keeps empty values unchanged."""
if value is None:
return None
text = str(value)
if text == "" or text.startswith(_ENC_PREFIX):
return text
token = _fernet().encrypt(text.encode("utf-8")).decode("utf-8")
return f"{_ENC_PREFIX}{token}"
def decrypt_text(value):
"""Decrypt an encrypted text value. Returns original value if not encrypted."""
if value is None:
return None
text = str(value)
if not text.startswith(_ENC_PREFIX):
return text
token = text[len(_ENC_PREFIX):]
try:
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError, TypeError):
# Keep data readable even if key rotation or malformed data occurred.
return text
def encrypt_document_fields(document, fields):
"""Encrypt selected fields of a document in-place and return it."""
for field in fields:
if field in document:
document[field] = encrypt_text(document.get(field))
return document
def decrypt_document_fields(document, fields):
"""Decrypt selected fields of a document in-place and return it."""
for field in fields:
if field in document:
document[field] = decrypt_text(document.get(field))
return document
def _candidate_media_paths(filename):
"""Return all possible filesystem paths for a stored media filename."""
name_part, _ = os.path.splitext(filename)
return [
(os.path.join(cfg.UPLOAD_FOLDER, filename), "originals"),
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.webp"), "originals"),
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.jpg"), "originals"),
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.webp"), "thumbnails"),
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.jpg"), "thumbnails"),
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.webp"), "previews"),
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.jpg"), "previews"),
]
def encrypt_soft_deleted_media_pack(item_docs, *, actor="system"):
"""
Archive media files referenced by item docs, encrypt the archive, and delete originals.
Uses ZIP_STORED (no compression) to keep CPU usage low.
"""
files_to_archive = []
seen_paths = set()
for item in item_docs:
item_id = str(item.get("_id", "unknown"))
for image_name in item.get("Images", []) or []:
for abs_path, bucket in _candidate_media_paths(str(image_name)):
if abs_path in seen_paths:
continue
if not os.path.isfile(abs_path):
continue
seen_paths.add(abs_path)
files_to_archive.append((item_id, str(image_name), abs_path, bucket))
if not files_to_archive:
return {
"archive_created": False,
"archived_files": 0,
"deleted_files": 0,
"archive_path": None,
}
os.makedirs(cfg.DELETED_ARCHIVE_FOLDER, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
archive_id = f"softdelete-{timestamp}-{uuid.uuid4().hex[:8]}"
zip_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip")
encrypted_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip.enc")
manifest = {
"archive_id": archive_id,
"created_at": datetime.utcnow().isoformat() + "Z",
"actor": actor,
"files": [],
}
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_STORED) as zf:
for idx, (item_id, original_name, abs_path, bucket) in enumerate(files_to_archive, start=1):
safe_name = os.path.basename(abs_path)
arcname = f"{bucket}/{item_id}/{idx:04d}-{safe_name}"
zf.write(abs_path, arcname)
manifest["files"].append(
{
"item_id": item_id,
"source_name": original_name,
"stored_as": arcname,
"size_bytes": os.path.getsize(abs_path),
}
)
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
with open(zip_path, "rb") as source_file:
encrypted_payload = _fernet().encrypt(source_file.read())
with open(encrypted_path, "wb") as encrypted_file:
encrypted_file.write(encrypted_payload)
deleted_files = 0
for _, _, abs_path, _ in files_to_archive:
try:
os.remove(abs_path)
deleted_files += 1
except OSError:
pass
try:
os.remove(zip_path)
except OSError:
pass
return {
"archive_created": True,
"archived_files": len(files_to_archive),
"deleted_files": deleted_files,
"archive_path": encrypted_path,
}
+2 -1
View File
@@ -11,4 +11,5 @@ pytz
requests
reportlab
python-barcode
openpyxl
openpyxl
cryptography
+108 -12
View File
@@ -12,6 +12,8 @@ defaults for the web application and helper modules.
"""
import os
import json
import atexit
from threading import Lock
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
@@ -56,6 +58,7 @@ DEFAULTS = {
'paths': {
'backups': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'backups'),
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'),
'deleted_archives': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'deleted_archives'),
},
'schoolPeriods': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
@@ -99,10 +102,27 @@ def _get(conf, path, default):
return default
return cur
def _get_bool_env(name, default):
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in ('1', 'true', 'yes', 'on')
def _get_int_env(name, default):
value = os.getenv(name)
if value is None or not value.strip():
return int(default)
try:
return int(value)
except ValueError:
return int(default)
# Expose settings
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
DEBUG = _get(_conf, ['dbg'], DEFAULTS['debug'])
SECRET_KEY = str(_get(_conf, ['key'], DEFAULTS['secret_key']))
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
HOST = _get(_conf, ['host'], DEFAULTS['host'])
PORT = _get(_conf, ['port'], DEFAULTS['port'])
@@ -115,6 +135,13 @@ MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
MONGODB_CONNECT_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_CONNECT_TIMEOUT_MS', 5000)
MONGODB_SERVER_SELECTION_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SERVER_SELECTION_TIMEOUT_MS', 5000)
MONGODB_SOCKET_TIMEOUT_MS = _get_int_env('INVENTAR_MONGODB_SOCKET_TIMEOUT_MS', 30000)
MONGODB_MAX_CONNECTING = _get_int_env('INVENTAR_MONGODB_MAX_CONNECTING', 2)
# Scheduler
SCHEDULER_INTERVAL_MIN = _get(_conf, ['scheduler', 'interval_minutes'], DEFAULTS['scheduler']['interval_minutes'])
@@ -162,10 +189,12 @@ PREVIEW_SIZE = (int(PREVIEW_SIZE_LIST[0]), int(PREVIEW_SIZE_LIST[1])) if isinsta
BACKUP_FOLDER = _get(_conf, ['paths', 'backups'], DEFAULTS['paths']['backups'])
LOGS_FOLDER = _get(_conf, ['paths', 'logs'], DEFAULTS['paths']['logs'])
DELETED_ARCHIVE_FOLDER = _get(_conf, ['paths', 'deleted_archives'], DEFAULTS['paths']['deleted_archives'])
# Optional environment overrides for writable storage mounts.
BACKUP_FOLDER = os.getenv('INVENTAR_BACKUP_FOLDER', BACKUP_FOLDER)
LOGS_FOLDER = os.getenv('INVENTAR_LOGS_FOLDER', LOGS_FOLDER)
DELETED_ARCHIVE_FOLDER = os.getenv('INVENTAR_DELETED_ARCHIVE_FOLDER', DELETED_ARCHIVE_FOLDER)
# Normalize backup and logs paths to absolute paths (similar to upload folders) to avoid
# permission issues caused by relative paths resolving to unintended working dirs.
@@ -174,21 +203,88 @@ if not os.path.isabs(BACKUP_FOLDER):
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
if not os.path.isabs(DELETED_ARCHIVE_FOLDER):
DELETED_ARCHIVE_FOLDER = os.path.join(PROJECT_ROOT, DELETED_ARCHIVE_FOLDER)
# Optional key for field/file encryption at application level.
DATA_ENCRYPTION_KEY = os.getenv('INVENTAR_DATA_ENCRYPTION_KEY', '').strip()
_MONGO_CLIENT_CACHE = {}
_MONGO_CLIENT_LOCK = Lock()
class _MongoClientProxy:
def __init__(self, client):
self._client = client
def __getattr__(self, name):
return getattr(self._client, name)
def __getitem__(self, name):
return self._client[name]
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def close(self):
return None
def _close_cached_mongo_clients():
with _MONGO_CLIENT_LOCK:
clients = list(_MONGO_CLIENT_CACHE.values())
_MONGO_CLIENT_CACHE.clear()
for proxy in clients:
try:
proxy._client.close()
except Exception:
pass
atexit.register(_close_cached_mongo_clients)
def MongoClient(*args, **kwargs):
"""Return a lightweight MongoDB client configured from this settings module."""
"""Return a process-local MongoDB client configured from this settings module."""
explicit_host = 'host' in kwargs
explicit_port = 'port' in kwargs
host = args[0] if len(args) >= 1 else kwargs.pop('host', MONGODB_HOST)
port = args[1] if len(args) >= 2 else kwargs.pop('port', MONGODB_PORT)
client_kwargs = {
'maxPoolSize': 10,
'minPoolSize': 0,
'connectTimeoutMS': 5000,
'serverSelectionTimeoutMS': 5000,
'maxPoolSize': MONGODB_MAX_POOL_SIZE,
'minPoolSize': MONGODB_MIN_POOL_SIZE,
'maxIdleTimeMS': MONGODB_MAX_IDLE_TIME_MS,
'connectTimeoutMS': MONGODB_CONNECT_TIMEOUT_MS,
'serverSelectionTimeoutMS': MONGODB_SERVER_SELECTION_TIMEOUT_MS,
'socketTimeoutMS': MONGODB_SOCKET_TIMEOUT_MS,
'maxConnecting': MONGODB_MAX_CONNECTING,
'retryWrites': True,
'retryReads': True,
}
client_kwargs.update(kwargs)
# Preserve caller-provided positional host/port arguments.
# If none are passed, use configured defaults.
if args:
return _PyMongoClient(*args, **client_kwargs)
return _PyMongoClient(MONGODB_HOST, MONGODB_PORT, **client_kwargs)
if len(args) >= 2 and not explicit_host and not explicit_port:
mongo_args = args
else:
mongo_args = (host, port)
cache_key = (
mongo_args,
tuple(sorted((key, repr(value)) for key, value in client_kwargs.items())),
)
with _MONGO_CLIENT_LOCK:
cached_client = _MONGO_CLIENT_CACHE.get(cache_key)
if cached_client is not None:
return cached_client
client = _PyMongoClient(*mongo_args, **client_kwargs)
cached_client = _MongoClientProxy(client)
_MONGO_CLIENT_CACHE[cache_key] = cached_client
return cached_client
+3 -13
View File
@@ -7,7 +7,7 @@
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!DOCTYPE html>
<html lang="en" data-module="{{ CURRENT_MODULE }}">
<html lang="de" data-module="{{ CURRENT_MODULE }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
@@ -487,12 +487,7 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</li>
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('notifications_view') %}nav-active{% endif %}" href="{{ url_for('notifications_view') }}">
Benachrichtigungen
{% if unread_notification_count and unread_notification_count > 0 %}
<span class="notif-badge">{{ unread_notification_count }}</span>
{% endif %}
</a>
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
@@ -583,12 +578,7 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a>
</li>
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link quick-link-pill {% if current_path == url_for('notifications_view') %}nav-active{% endif %}" href="{{ url_for('notifications_view') }}">
Benachrichtigungen
{% if unread_notification_count and unread_notification_count > 0 %}
<span class="notif-badge">{{ unread_notification_count }}</span>
{% endif %}
</a>
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}">Tutorial</a>
</li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
+9 -8
View File
@@ -14,20 +14,21 @@
<div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded">
<h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 TMG)</p>
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
<div class="impressum-section mb-4">
<h3 class="mb-3">Kontaktinformationen</h3>
<p><strong>Name:</strong> . ..</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
<p><strong>E-Mail:</strong> <a href="mailto:kontakt@example.com">kontakt@example.com</a></p>
<p><strong>Telefon:</strong> +49 123 456789</p>
<address class="mb-0">
<p><strong>Name:</strong> Invario UG</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
</address>
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 55 Abs. 2 RStV)</p>
<p>...<br>
<p>(nach § 18 Abs. 2 MStV)</p>
<p>Invario UG<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland</p>
@@ -37,7 +38,7 @@
<h3 class="mb-3">Haftungsausschluss</h3>
<h4 class="mb-2">Haftung für Inhalte</h4>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<h4 class="mb-2">Haftung für Links</h4>
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
-5
View File
@@ -321,11 +321,6 @@
<li><strong>Einwilligung:</strong> Falls private Daten der Nutzer erfasst werden, muss eine explizite
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
</ol>
<div class="license-exception-notice" style="background-color:#f8d7da; border-color:#f5c2c7; border-left-color:#dc3545;">
<h3 style="color:#842029;">⚠️ Hinweis</h3>
<p>Dieses Dokument stellt <strong>keine Rechtsberatung</strong> dar. Bitte konsultieren Sie im Zweifelsfall
einen Fachanwalt für Datenschutzrecht.</p>
</div>
</div>
</div><!-- /#pane-legal -->
+6 -1
View File
@@ -30,6 +30,8 @@ services:
dockerfile: Dockerfile
container_name: inventarsystem-app
restart: unless-stopped
security_opt:
- no-new-privileges:true
depends_on:
mongodb:
condition: service_healthy
@@ -39,17 +41,19 @@ services:
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
volumes:
mongodb_data:
@@ -59,3 +63,4 @@ volumes:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
+2 -1
View File
@@ -10,4 +10,5 @@ pytz
requests
reportlab
python-barcode
openpyxl
openpyxl
cryptography