Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d8213b8c2 |
+82
-9
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -11,4 +11,5 @@ pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
openpyxl
|
||||
cryptography
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -10,4 +10,5 @@ pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
openpyxl
|
||||
openpyxl
|
||||
cryptography
|
||||
Reference in New Issue
Block a user