Compare commits

...

1 Commits

6 changed files with 273 additions and 11 deletions
+82 -9
View File
@@ -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:
+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
+8
View File
@@ -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 = {}
+3
View File
@@ -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:
+2 -1
View File
@@ -10,4 +10,5 @@ pytz
requests
reportlab
python-barcode
openpyxl
openpyxl
cryptography