diff --git a/Web/app.py b/Web/app.py index 54bd679..6724e50 100755 --- a/Web/app.py +++ b/Web/app.py @@ -56,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__)) @@ -93,6 +98,15 @@ def print(*args, **kwargs): 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 PREVIEW_SIZE = cfg.PREVIEW_SIZE @@ -2058,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}, @@ -2469,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']), @@ -2511,15 +2527,21 @@ 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') @@ -2538,13 +2560,19 @@ 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')) @@ -2554,6 +2582,7 @@ def student_cards_admin(): # 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( @@ -2590,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( @@ -2621,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( @@ -2660,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() @@ -2834,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: @@ -4509,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 @@ -4543,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', @@ -4551,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, } ) @@ -4559,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.', } @@ -4605,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') @@ -4656,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: diff --git a/Web/data_protection.py b/Web/data_protection.py new file mode 100644 index 0000000..e38c33e --- /dev/null +++ b/Web/data_protection.py @@ -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, + } diff --git a/Web/requirements.txt b/Web/requirements.txt index 5eee843..5612bd5 100755 --- a/Web/requirements.txt +++ b/Web/requirements.txt @@ -11,4 +11,5 @@ pytz requests reportlab python-barcode -openpyxl \ No newline at end of file +openpyxl +cryptography \ No newline at end of file diff --git a/Web/settings.py b/Web/settings.py index aecef73..8e4e485 100644 --- a/Web/settings.py +++ b/Web/settings.py @@ -58,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)"}, @@ -188,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. @@ -200,6 +203,11 @@ 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 = {} diff --git a/docker-compose.yml b/docker-compose.yml index 504f811..d5023a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ 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: @@ -52,6 +53,7 @@ services: - app_qrcodes:/app/Web/QRCodes - app_backups:/data/backups - app_logs:/data/logs + - app_deleted_archives:/data/deleted-archives volumes: mongodb_data: @@ -61,3 +63,4 @@ volumes: app_qrcodes: app_backups: app_logs: + app_deleted_archives: diff --git a/requirements.txt b/requirements.txt index 4624bda..1bc0810 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ pytz requests reportlab python-barcode -openpyxl \ No newline at end of file +openpyxl +cryptography \ No newline at end of file