Files
Inventarsystem/Web/modules/inventarsystem/data_protection.py
T
Aiirondev_dev 0c27d7ac86 feat: Add Excel, PDF export, and user generation modules
- Implemented `excel_export.py` for generating library item exports in Excel format.
- Created `pdf_export.py` for generating audit reports compliant with DIN 5008 standards, including detailed event tables and signature blocks.
- Developed `generate_user.py` for interactive user creation with validation for usernames and passwords.
- Introduced `module_registry.py` for managing module states and path matching.
- Added a basic `__init__.py` in the `terminplaner` module for initialization.
2026-05-20 15:30:23 +02:00

198 lines
6.4 KiB
Python

"""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 Web.modules.database.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 _iter_item_image_names(item):
"""Yield image names from current and legacy item schema fields."""
images_field = item.get("Images", []) or []
if isinstance(images_field, (list, tuple, set)):
for value in images_field:
text = str(value).strip()
if text:
yield text
elif isinstance(images_field, str):
text = images_field.strip()
if text:
yield text
# Legacy schema support: single image name in `Image`.
legacy_image = item.get("Image")
if legacy_image:
text = str(legacy_image).strip()
if text:
yield text
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 _iter_item_image_names(item):
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,
}