fix(api): hide campaign operational internals
This commit is contained in:
134
src/govoplan_campaign/backend/response_security.py
Normal file
134
src/govoplan_campaign/backend/response_security.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
# These fields locate process-local or storage-backend resources, or authorize
|
||||
# a worker claim. They are useful for tightly controlled diagnostics but are
|
||||
# not part of the campaign business-data contract.
|
||||
CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
|
||||
{
|
||||
"campaign_file",
|
||||
"claim_token",
|
||||
"eml_local_path",
|
||||
"eml_path",
|
||||
"eml_storage_key",
|
||||
"local_path",
|
||||
"source_base_path",
|
||||
"storage_bucket",
|
||||
"storage_key",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def public_campaign_payload(value: Any) -> Any:
|
||||
"""Return a detached payload without infrastructure-only locators."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: public_campaign_payload(item)
|
||||
for key, item in value.items()
|
||||
if key not in CAMPAIGN_INTERNAL_RESPONSE_KEYS
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [public_campaign_payload(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(public_campaign_payload(item) for item in value)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def public_campaign_configuration(value: Any) -> Any:
|
||||
"""Return campaign JSON without infrastructure locators or mail secrets.
|
||||
|
||||
Password-named business fields are intentionally retained. Only the
|
||||
schema-defined SMTP/IMAP credential paths under ``server`` are secrets.
|
||||
"""
|
||||
|
||||
payload = public_campaign_payload(value)
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
server = payload.get("server")
|
||||
if isinstance(server, dict):
|
||||
for protocol in ("smtp", "imap"):
|
||||
config = server.get(protocol)
|
||||
if isinstance(config, dict):
|
||||
config.pop("password", None)
|
||||
credentials = server.get("credentials")
|
||||
if isinstance(credentials, dict):
|
||||
for protocol in ("smtp", "imap"):
|
||||
config = credentials.get(protocol)
|
||||
if isinstance(config, dict):
|
||||
config.pop("password", None)
|
||||
_sanitize_configuration_paths(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def public_source_filename(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return text
|
||||
windows_path = PureWindowsPath(text)
|
||||
return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name
|
||||
|
||||
|
||||
def _sanitize_configuration_paths(payload: dict[str, Any]) -> None:
|
||||
template = payload.get("template")
|
||||
source = template.get("source") if isinstance(template, dict) else None
|
||||
if isinstance(source, dict):
|
||||
for key in ("subject_path", "text_path", "html_path"):
|
||||
if key in source:
|
||||
source[key] = _public_configuration_path(source[key])
|
||||
|
||||
entries = payload.get("entries")
|
||||
entry_source = entries.get("source") if isinstance(entries, dict) else None
|
||||
if isinstance(entry_source, dict) and "path" in entry_source:
|
||||
entry_source["path"] = _public_configuration_path(entry_source["path"])
|
||||
|
||||
attachments = payload.get("attachments")
|
||||
if isinstance(attachments, dict):
|
||||
if "base_path" in attachments:
|
||||
attachments["base_path"] = _public_configuration_path(attachments["base_path"])
|
||||
base_paths = attachments.get("base_paths")
|
||||
if isinstance(base_paths, list):
|
||||
for item in base_paths:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in ("path", "source"):
|
||||
if key in item:
|
||||
item[key] = _public_configuration_path(item[key])
|
||||
_sanitize_attachment_rules(attachments.get("global"))
|
||||
|
||||
if isinstance(entries, dict):
|
||||
inline_entries = entries.get("inline")
|
||||
if isinstance(inline_entries, list):
|
||||
for entry in inline_entries:
|
||||
if isinstance(entry, dict):
|
||||
_sanitize_attachment_rules(entry.get("attachments"))
|
||||
defaults = entries.get("defaults")
|
||||
if isinstance(defaults, dict):
|
||||
_sanitize_attachment_rules(defaults.get("attachments"))
|
||||
|
||||
|
||||
def _sanitize_attachment_rules(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for rule in value:
|
||||
if isinstance(rule, dict) and "base_dir" in rule:
|
||||
rule["base_dir"] = _public_configuration_path(rule["base_dir"])
|
||||
|
||||
|
||||
def _public_configuration_path(value: Any) -> Any:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return value
|
||||
text = value.strip()
|
||||
windows_path = PureWindowsPath(text)
|
||||
path = Path(text)
|
||||
if windows_path.is_absolute():
|
||||
return windows_path.name
|
||||
if path.is_absolute() or text.startswith("~"):
|
||||
return path.name
|
||||
return value
|
||||
Reference in New Issue
Block a user