0c27d7ac86
- 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.
198 lines
6.4 KiB
Python
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,
|
|
}
|