Files
GIA/core/views/system.py

1113 lines
42 KiB
Python

import time
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from core.clients import transport
from core.events.projection import shadow_compare_session
from core.memory.search_backend import backend_status, get_memory_search_backend
from core.models import (
AdapterHealthEvent,
AIRequest,
AIResult,
AIResultSignal,
Chat,
ChatSession,
CommandSecurityPolicy,
ConversationEvent,
Group,
MemoryItem,
Message,
MessageEvent,
PatternArtifactExport,
PatternMitigationAutoSettings,
PatternMitigationCorrection,
PatternMitigationGame,
PatternMitigationMessage,
PatternMitigationPlan,
PatternMitigationRule,
Person,
Persona,
PersonIdentifier,
QueuedMessage,
UserAccessibilitySettings,
UserXmppOmemoState,
UserXmppOmemoTrustedKey,
UserXmppSecuritySettings,
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
from core.transports.capabilities import capability_snapshot
from core.views.manage.permissions import SuperUserRequiredMixin
class SystemSettings(SuperUserRequiredMixin, View):
template_name = "pages/system-settings.html"
def _counts(self, user):
return {
"chat_sessions": ChatSession.objects.filter(user=user).count(),
"messages": Message.objects.filter(user=user).count(),
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
"message_events": MessageEvent.objects.filter(user=user).count(),
"conversation_events": ConversationEvent.objects.filter(user=user).count(),
"adapter_health_events": AdapterHealthEvent.objects.filter(
user=user
).count(),
"workspace_conversations": WorkspaceConversation.objects.filter(
user=user
).count(),
"workspace_snapshots": WorkspaceMetricSnapshot.objects.filter(
conversation__user=user
).count(),
"ai_requests": AIRequest.objects.filter(user=user).count(),
"ai_results": AIResult.objects.filter(user=user).count(),
"ai_result_signals": AIResultSignal.objects.filter(user=user).count(),
"memory_items": MemoryItem.objects.filter(user=user).count(),
"mitigation_plans": PatternMitigationPlan.objects.filter(user=user).count(),
"mitigation_rules": PatternMitigationRule.objects.filter(user=user).count(),
"mitigation_games": PatternMitigationGame.objects.filter(user=user).count(),
"mitigation_corrections": PatternMitigationCorrection.objects.filter(
user=user
).count(),
"mitigation_messages": PatternMitigationMessage.objects.filter(
user=user
).count(),
"mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter(
user=user
).count(),
"mitigation_exports": PatternArtifactExport.objects.filter(
user=user
).count(),
"osint_people": Person.objects.filter(user=user).count(),
"osint_identifiers": PersonIdentifier.objects.filter(user=user).count(),
"osint_groups": Group.objects.filter(user=user).count(),
"osint_personas": Persona.objects.filter(user=user).count(),
}
def _purge_non_osint(self, user):
deleted = 0
deleted += PatternArtifactExport.objects.filter(user=user).delete()[0]
deleted += PatternMitigationMessage.objects.filter(user=user).delete()[0]
deleted += PatternMitigationCorrection.objects.filter(user=user).delete()[0]
deleted += PatternMitigationGame.objects.filter(user=user).delete()[0]
deleted += PatternMitigationRule.objects.filter(user=user).delete()[0]
deleted += PatternMitigationAutoSettings.objects.filter(user=user).delete()[0]
deleted += PatternMitigationPlan.objects.filter(user=user).delete()[0]
deleted += AIResultSignal.objects.filter(user=user).delete()[0]
deleted += AIResult.objects.filter(user=user).delete()[0]
deleted += AIRequest.objects.filter(user=user).delete()[0]
deleted += MemoryItem.objects.filter(user=user).delete()[0]
deleted += WorkspaceMetricSnapshot.objects.filter(
conversation__user=user
).delete()[0]
deleted += MessageEvent.objects.filter(user=user).delete()[0]
deleted += ConversationEvent.objects.filter(user=user).delete()[0]
deleted += AdapterHealthEvent.objects.filter(user=user).delete()[0]
deleted += Message.objects.filter(user=user).delete()[0]
deleted += QueuedMessage.objects.filter(user=user).delete()[0]
deleted += WorkspaceConversation.objects.filter(user=user).delete()[0]
deleted += ChatSession.objects.filter(user=user).delete()[0]
# Chat rows are legacy Signal cache rows and are not user-scoped.
deleted += Chat.objects.all().delete()[0]
return deleted
def _purge_osint_people(self, user):
return Person.objects.filter(user=user).delete()[0]
def _purge_osint_identifiers(self, user):
return PersonIdentifier.objects.filter(user=user).delete()[0]
def _purge_osint_groups(self, user):
return Group.objects.filter(user=user).delete()[0]
def _purge_osint_personas(self, user):
return Persona.objects.filter(user=user).delete()[0]
def _handle_action(self, request):
action = str(request.POST.get("action") or "").strip().lower()
if action == "purge_non_osint":
return (
"success",
f"Purged {self._purge_non_osint(request.user)} non-OSINT row(s).",
)
if action == "purge_osint_people":
return (
"warning",
f"Purged {self._purge_osint_people(request.user)} OSINT people row(s).",
)
if action == "purge_osint_identifiers":
return (
"warning",
f"Purged {self._purge_osint_identifiers(request.user)} OSINT identifier row(s).",
)
if action == "purge_osint_groups":
return (
"warning",
f"Purged {self._purge_osint_groups(request.user)} OSINT group row(s).",
)
if action == "purge_osint_personas":
return (
"warning",
f"Purged {self._purge_osint_personas(request.user)} OSINT persona row(s).",
)
return ("danger", "Unknown action.")
def _diagnostics_options(self, user):
session_rows = list(
ChatSession.objects.filter(user=user)
.select_related("identifier", "identifier__person")
.order_by("-last_interaction", "-id")[:120]
)
session_options = []
for row in session_rows:
identifier = getattr(row, "identifier", None)
person = getattr(identifier, "person", None) if identifier else None
session_options.append(
{
"id": str(row.id),
"label": " | ".join(
[
str(getattr(person, "name", "") or "-"),
str(row.id),
str(getattr(identifier, "service", "") or "-"),
str(getattr(identifier, "identifier", "") or "-"),
]
),
}
)
trace_options = []
seen_trace_ids = set()
for trace_id in (
ConversationEvent.objects.filter(user=user)
.exclude(trace_id="")
.order_by("-ts")
.values_list("trace_id", flat=True)[:400]
):
value = str(trace_id or "").strip()
if not value or value in seen_trace_ids:
continue
seen_trace_ids.add(value)
trace_options.append(value)
if len(trace_options) >= 120:
break
service_candidates = {"signal", "whatsapp", "xmpp", "instagram", "web"}
service_candidates.update(
str(item or "").strip().lower()
for item in ConversationEvent.objects.filter(user=user)
.exclude(origin_transport="")
.values_list("origin_transport", flat=True)
.distinct()[:50]
)
service_options = sorted(value for value in service_candidates if value)
event_type_candidates = {
"message_created",
"reaction_added",
"reaction_removed",
"read_receipt",
"message_updated",
"message_deleted",
}
event_type_candidates.update(
str(item or "").strip().lower()
for item in ConversationEvent.objects.filter(user=user)
.exclude(event_type="")
.values_list("event_type", flat=True)
.distinct()[:80]
)
event_type_options = sorted(value for value in event_type_candidates if value)
return {
"sessions": session_options,
"trace_ids": trace_options,
"services": service_options,
"event_types": event_type_options,
}
def _render_page(self, request, notice_level="", notice_message=""):
return render(
request,
self.template_name,
{
"counts": self._counts(request.user),
"notice_level": notice_level,
"notice_message": notice_message,
"diagnostics_options": self._diagnostics_options(request.user),
},
)
def get(self, request):
return self._render_page(request)
def post(self, request):
notice_level, notice_message = self._handle_action(request)
return self._render_page(
request,
notice_level=notice_level,
notice_message=notice_message,
)
class ServiceCapabilitySnapshotAPI(SuperUserRequiredMixin, View):
def get(self, request):
service = str(request.GET.get("service") or "").strip().lower()
return JsonResponse(
{
"ok": True,
"data": capability_snapshot(service),
}
)
class AdapterHealthSummaryAPI(SuperUserRequiredMixin, View):
def get(self, request):
latest_by_service = {}
rows = AdapterHealthEvent.objects.order_by("service", "-ts")[:200]
for row in rows:
key = str(row.service or "").strip().lower()
if key in latest_by_service:
continue
latest_by_service[key] = {
"status": str(row.status or ""),
"reason": str(row.reason or ""),
"ts": int(row.ts or 0),
"created_at": row.created_at.isoformat(),
}
return JsonResponse({"ok": True, "services": latest_by_service})
class TraceDiagnosticsAPI(SuperUserRequiredMixin, View):
def get(self, request):
trace_id = str(request.GET.get("trace_id") or "").strip()
if not trace_id:
return JsonResponse(
{"ok": False, "error": "trace_id_required"},
status=400,
)
rows = list(
ConversationEvent.objects.filter(
user=request.user,
trace_id=trace_id,
)
.select_related("session")
.order_by("ts", "created_at")[:500]
)
related_session_ids = []
seen_sessions = set()
for row in rows:
session_id = str(row.session_id or "").strip()
if not session_id or session_id in seen_sessions:
continue
seen_sessions.add(session_id)
related_session_ids.append(session_id)
return JsonResponse(
{
"ok": True,
"trace_id": trace_id,
"count": len(rows),
"related_session_ids": related_session_ids,
"projection_shadow_urls": [
f"{reverse('system_projection_shadow')}?session_id={session_id}"
for session_id in related_session_ids
],
"events": [
{
"id": str(row.id),
"ts": int(row.ts or 0),
"event_type": str(row.event_type or ""),
"direction": str(row.direction or ""),
"session_id": str(row.session_id or ""),
"projection_shadow_url": (
f"{reverse('system_projection_shadow')}?session_id={str(row.session_id or '').strip()}"
if str(row.session_id or "").strip()
else ""
),
"origin_transport": str(row.origin_transport or ""),
"origin_message_id": str(row.origin_message_id or ""),
"payload": dict(row.payload or {}),
}
for row in rows
],
}
)
class EventProjectionShadowAPI(SuperUserRequiredMixin, View):
def get(self, request):
session_id = str(request.GET.get("session_id") or "").strip()
if not session_id:
return JsonResponse(
{"ok": False, "error": "session_id_required"},
status=400,
)
detail_limit = int(request.GET.get("detail_limit") or 25)
session = ChatSession.objects.filter(
id=session_id,
user=request.user,
).first()
if session is None:
return JsonResponse(
{"ok": False, "error": "session_not_found"},
status=404,
)
compared = shadow_compare_session(session, detail_limit=max(0, detail_limit))
return JsonResponse(
{
"ok": True,
"result": compared,
"cause_summary": dict(compared.get("cause_counts") or {}),
"cause_samples": dict(compared.get("cause_samples") or {}),
}
)
class EventLedgerSmokeAPI(SuperUserRequiredMixin, View):
def get(self, request):
minutes = max(1, int(request.GET.get("minutes") or 120))
service = str(request.GET.get("service") or "").strip().lower()
user_id = str(request.GET.get("user_id") or "").strip() or str(request.user.id)
limit = max(1, min(500, int(request.GET.get("limit") or 200)))
require_types_raw = str(request.GET.get("require_types") or "").strip()
required_types = [
item.strip().lower()
for item in require_types_raw.split(",")
if item.strip()
]
cutoff_ts = int(time.time() * 1000) - (minutes * 60 * 1000)
queryset = ConversationEvent.objects.filter(ts__gte=cutoff_ts).order_by("-ts")
if service:
queryset = queryset.filter(origin_transport=service)
if user_id:
queryset = queryset.filter(user_id=user_id)
rows = list(
queryset.values(
"id",
"user_id",
"session_id",
"ts",
"event_type",
"direction",
"origin_transport",
"trace_id",
)[:limit]
)
event_type_counts = {}
for row in rows:
key = str(row.get("event_type") or "")
event_type_counts[key] = int(event_type_counts.get(key) or 0) + 1
missing_required_types = [
event_type
for event_type in required_types
if int(event_type_counts.get(event_type) or 0) <= 0
]
return JsonResponse(
{
"ok": True,
"minutes": minutes,
"service": service,
"user_id": user_id,
"count": len(rows),
"event_type_counts": event_type_counts,
"required_types": required_types,
"missing_required_types": missing_required_types,
"sample": rows[:25],
}
)
class MemorySearchStatusAPI(SuperUserRequiredMixin, View):
def get(self, request):
return JsonResponse({"ok": True, "status": backend_status()})
class MemorySearchQueryAPI(SuperUserRequiredMixin, View):
def get(self, request):
query = str(request.GET.get("q") or "").strip()
user_id = int(request.GET.get("user_id") or request.user.id)
conversation_id = str(request.GET.get("conversation_id") or "").strip()
limit = max(1, min(50, int(request.GET.get("limit") or 20)))
statuses = tuple(
item.strip().lower()
for item in str(request.GET.get("statuses") or "active").split(",")
if item.strip()
)
if not query:
return JsonResponse({"ok": False, "error": "query_required"}, status=400)
backend = get_memory_search_backend()
hits = backend.search(
user_id=user_id,
query=query,
conversation_id=conversation_id,
limit=limit,
include_statuses=statuses,
)
return JsonResponse(
{
"ok": True,
"backend": getattr(backend, "name", "unknown"),
"query": query,
"count": len(hits),
"hits": [
{
"memory_id": item.memory_id,
"score": item.score,
"summary": item.summary,
"payload": item.payload,
}
for item in hits
],
}
)
def _parse_xmpp_jid(jid_str: str) -> dict:
"""Split a full JID (localpart@domain/resource) into components."""
raw = str(jid_str or "").strip()
bare, _, resource = raw.partition("/")
localpart, _, domain = bare.partition("@")
return {
"full": raw,
"bare": bare,
"localpart": localpart,
"domain": domain,
"resource": resource,
}
def _parse_omemo_client_key(client_key: str) -> dict:
raw = str(client_key or "").strip()
parts = {}
for row in raw.split(","):
key, _, value = str(row or "").partition(":")
k = key.strip().lower()
v = value.strip()
if k and v:
parts[k] = v
sid = str(parts.get("sid") or "").strip()
rid = str(parts.get("rid") or "").strip()
return {
"raw": raw,
"sid": sid,
"rid": rid,
"has_ids": bool(sid or rid),
}
def _latest_client_omemo_fingerprint(omemo_row) -> str:
if omemo_row is None:
return ""
details = getattr(omemo_row, "details", {}) or {}
if not isinstance(details, dict):
return ""
return str(details.get("latest_client_fingerprint") or "").strip()
def _to_bool(value, default=False):
if value is None:
return bool(default)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on", "y"}:
return True
if text in {"0", "false", "no", "off", "n"}:
return False
return bool(default)
class SecurityPage(LoginRequiredMixin, View):
"""Security settings page for OMEMO and command-scope policy controls."""
template_name = "pages/security.html"
page_mode = "encryption"
GLOBAL_SCOPE_KEY = "global.override"
# Allowed Services list used by both Global Scope Override and local scopes.
# Keep this in sync with the UI text on the Security page.
POLICY_SERVICES = ["xmpp", "whatsapp", "signal", "instagram", "web"]
# Override mode names as shown in the interface:
# - per_scope: local scope controls remain editable
# - on/off: global override forces each local scope value
OVERRIDE_OPTIONS = ("per_scope", "on", "off")
GLOBAL_OVERRIDE_FIELDS = (
"scope_enabled",
"require_omemo",
"require_trusted_fingerprint",
)
POLICY_SCOPES = [
(
"gateway.tasks",
"Gateway .tasks commands",
"Handles .tasks list/show/complete/undo over gateway channels.",
),
(
"gateway.approval",
"Gateway approval commands",
"Handles .approval/.codex/.claude approve/deny over gateway channels.",
),
(
"gateway.totp",
"Gateway TOTP enrollment",
"Controls TOTP enrollment/status commands over gateway channels.",
),
(
"tasks.submit",
"Task submissions from chat",
"Controls automatic task creation from inbound messages.",
),
(
"tasks.commands",
"Task command verbs (.task/.undo/.epic)",
"Controls explicit task command verbs.",
),
(
"command.bp",
"Business plan command",
"Controls Business Plan command execution.",
),
("command.codex", "Codex command", "Controls Codex command execution."),
("command.claude", "Claude command", "Controls Claude command execution."),
]
POLICY_GROUP_LABELS = {
"gateway": "Gateway",
"tasks": "Tasks",
"command": "Commands",
"agentic": "Agentic",
"other": "Other",
}
def _show_encryption(self) -> bool:
return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
"encryption",
"all",
}
def _show_permission(self) -> bool:
return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
"permission",
"all",
}
def _security_settings(self, request):
row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user)
return row
def _omemo_discovered_keys(self, request, xmpp_state, omemo_row):
discovered = []
sender = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
client_jid = str(sender.get("bare") or "").strip()
client_fingerprint = _latest_client_omemo_fingerprint(omemo_row)
client_key = str(getattr(omemo_row, "latest_client_key", "") or "").strip()
if client_jid and client_fingerprint:
discovered.append(
{
"jid": client_jid,
"key_type": "fingerprint",
"key_id": client_fingerprint,
"source": "client_observed",
"label": "Observed client fingerprint",
}
)
elif client_jid and client_key:
discovered.append(
{
"jid": client_jid,
"key_type": "client_key",
"key_id": client_key,
"source": "client_observed",
"label": "Observed client key",
}
)
# De-duplicate while preserving order.
deduped = []
seen = set()
for row in discovered:
key = (
str(row.get("jid") or "").strip().lower(),
str(row.get("key_type") or "").strip().lower(),
str(row.get("key_id") or "").strip(),
)
if key in seen:
continue
seen.add(key)
deduped.append(row)
discovered = deduped
trusted_map = {}
rows = UserXmppOmemoTrustedKey.objects.filter(user=request.user)
for item in rows:
trusted_map[
(
str(item.jid or "").strip().lower(),
str(item.key_type or "").strip().lower(),
str(item.key_id or "").strip(),
)
] = item
payload = []
for row in discovered:
map_key = (
str(row.get("jid") or "").strip().lower(),
str(row.get("key_type") or "").strip().lower(),
str(row.get("key_id") or "").strip(),
)
existing = trusted_map.get(map_key)
payload.append(
{
**row,
"trusted": bool(getattr(existing, "trusted", False)),
"updated_at": getattr(existing, "updated_at", None),
}
)
return payload
def _parse_override_value(self, value):
option = str(value or "").strip().lower()
if option == "inherit":
# Backward-compat for existing persisted values.
option = "per_scope"
if option in self.OVERRIDE_OPTIONS:
return option
return "per_scope"
def _global_override_payload(self, request):
row, _ = CommandSecurityPolicy.objects.get_or_create(
user=request.user,
scope_key=self.GLOBAL_SCOPE_KEY,
defaults={
"enabled": True,
"allowed_services": [],
"allowed_channels": {},
"settings": {},
},
)
settings_payload = dict(row.settings or {})
values = {
"scope_enabled": self._parse_override_value(
settings_payload.get("scope_enabled")
),
"require_omemo": self._parse_override_value(
settings_payload.get("require_omemo")
),
"require_trusted_fingerprint": self._parse_override_value(
settings_payload.get("require_trusted_fingerprint")
),
}
allowed_services = [
str(value or "").strip().lower()
for value in (row.allowed_services or [])
if str(value or "").strip()
]
channel_rules = self._channel_rules_from_map(dict(row.allowed_channels or {}))
if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}]
return {
"row": row,
"values": values,
"allowed_services": allowed_services,
"channel_rules": channel_rules,
}
def _apply_global_override(self, current_value: bool, option: str) -> bool:
normalized = self._parse_override_value(option)
if normalized == "on":
return True
if normalized == "off":
return False
return bool(current_value)
def _channel_rules_from_map(self, source_map):
rows = []
raw = dict(source_map or {})
for service_key, patterns in raw.items():
service_name = str(service_key or "").strip().lower()
if not service_name:
continue
if isinstance(patterns, list):
for pattern in patterns:
pattern_text = str(pattern or "").strip()
if pattern_text:
rows.append(
{
"service": service_name,
"pattern": pattern_text,
}
)
return rows
def _channels_map_from_post(self, request):
channel_services = request.POST.getlist("allowed_channel_service")
channel_patterns = request.POST.getlist("allowed_channel_pattern")
allowed_channels: dict[str, list[str]] = {}
for idx, raw_pattern in enumerate(channel_patterns):
pattern = str(raw_pattern or "").strip()
if not pattern:
continue
service_name = (
str(channel_services[idx] if idx < len(channel_services) else "")
.strip()
.lower()
)
if not service_name:
service_name = "*"
allowed_channels.setdefault(service_name, [])
if pattern not in allowed_channels[service_name]:
allowed_channels[service_name].append(pattern)
return allowed_channels
def _scope_rows(self, request):
global_overrides = self._global_override_payload(request)["values"]
security_settings = self._security_settings(request)
rows = {
str(item.scope_key or "").strip().lower(): item
for item in CommandSecurityPolicy.objects.filter(user=request.user).exclude(
scope_key=self.GLOBAL_SCOPE_KEY
)
}
payload = []
for scope_key, label, description in self.POLICY_SCOPES:
key = str(scope_key or "").strip().lower()
item = rows.get(key)
raw_allowed_services = [
str(value or "").strip().lower()
for value in (getattr(item, "allowed_services", []) or [])
if str(value or "").strip()
]
channel_rules = self._channel_rules_from_map(
dict(getattr(item, "allowed_channels", {}) or {})
)
if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}]
enabled_locked = global_overrides["scope_enabled"] != "per_scope"
require_omemo_locked = global_overrides[
"require_omemo"
] != "per_scope" or bool(security_settings.require_omemo)
require_trusted_locked = (
global_overrides["require_trusted_fingerprint"] != "per_scope"
)
payload.append(
{
"scope_key": key,
"label": label,
"description": description,
"enabled": self._apply_global_override(
bool(getattr(item, "enabled", True)),
global_overrides["scope_enabled"],
),
"require_omemo": self._apply_global_override(
bool(getattr(item, "require_omemo", False)),
global_overrides["require_omemo"],
),
"require_trusted_fingerprint": self._apply_global_override(
bool(getattr(item, "require_trusted_omemo_fingerprint", False)),
global_overrides["require_trusted_fingerprint"],
),
"enabled_locked": enabled_locked,
"require_omemo_locked": require_omemo_locked,
"require_trusted_fingerprint_locked": require_trusted_locked,
"lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.",
"require_omemo_lock_help": (
"Disable 'Require OMEMO encryption' in Encryption settings to edit this field."
if bool(security_settings.require_omemo)
else "Set this field to 'Per Scope' in Global Scope Override to edit it here."
),
"allowed_services": raw_allowed_services,
"channel_rules": channel_rules,
}
)
return payload
def _scope_group_key(self, scope_key: str) -> str:
key = str(scope_key or "").strip().lower()
if key in {"tasks.commands", "gateway.tasks"}:
return "tasks"
if key in {"command.codex", "command.claude"}:
return "agentic"
if key.startswith("gateway."):
return "command"
if key.startswith("tasks."):
if key == "tasks.submit":
return "tasks"
return "command"
if key.startswith("command."):
return "command"
if ".commands" in key:
return "command"
if ".approval" in key:
return "command"
if ".totp" in key:
return "command"
if ".task" in key:
return "tasks"
return "other"
def _grouped_scope_rows(self, request):
rows = self._scope_rows(request)
grouped: dict[str, list[dict]] = {key: [] for key in self.POLICY_GROUP_LABELS}
for row in rows:
group_key = self._scope_group_key(row.get("scope_key"))
grouped.setdefault(group_key, [])
grouped[group_key].append(row)
payload = []
for group_key in ("tasks", "command", "agentic", "other"):
items = grouped.get(group_key) or []
if not items:
continue
payload.append(
{
"key": group_key,
"label": self.POLICY_GROUP_LABELS.get(group_key, group_key.title()),
"rows": items,
}
)
return payload
def post(self, request):
row = self._security_settings(request)
if str(request.POST.get("encryption_settings_submit") or "").strip() == "1":
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
row.encrypt_contact_messages_with_omemo = _to_bool(
request.POST.get("encrypt_contact_messages_with_omemo"),
False,
)
row.save(
update_fields=[
"require_omemo",
"encrypt_contact_messages_with_omemo",
"updated_at",
]
)
if "omemo_trust_update" in request.POST:
key_type = (
str(request.POST.get("key_type") or "fingerprint").strip().lower()
)
if key_type not in {"fingerprint", "client_key"}:
key_type = "fingerprint"
jid = str(request.POST.get("jid") or "").strip()
key_id = str(request.POST.get("key_id") or "").strip()
source = str(request.POST.get("source") or "").strip().lower()
trusted = _to_bool(request.POST.get("trusted"), False)
if jid and key_id:
trust_row, _ = UserXmppOmemoTrustedKey.objects.get_or_create(
user=request.user,
jid=jid,
key_type=key_type,
key_id=key_id,
defaults={"source": source},
)
trust_row.trusted = trusted
if source:
trust_row.source = source
trust_row.save(update_fields=["trusted", "source", "updated_at"])
redirect_to = HttpResponseRedirect(request.path)
scope_key = str(request.POST.get("scope_key") or "").strip().lower()
if not self._show_permission():
return redirect_to
if scope_key == self.GLOBAL_SCOPE_KEY:
global_row = self._global_override_payload(request)["row"]
security_settings = self._security_settings(request)
settings_payload = dict(global_row.settings or {})
for field in self.GLOBAL_OVERRIDE_FIELDS:
if field == "require_omemo" and bool(security_settings.require_omemo):
continue
settings_payload[field] = self._parse_override_value(
request.POST.get(f"global_{field}")
)
global_row.allowed_services = [
str(item or "").strip().lower()
for item in request.POST.getlist("allowed_services")
if str(item or "").strip()
]
global_row.allowed_channels = self._channels_map_from_post(request)
global_row.settings = settings_payload
global_row.save(
update_fields=[
"settings",
"allowed_services",
"allowed_channels",
"updated_at",
]
)
return redirect_to
if scope_key:
if str(request.POST.get("scope_change_mode") or "").strip() != "1":
return redirect_to
global_overrides = self._global_override_payload(request)["values"]
security_settings = self._security_settings(request)
allowed_services = [
str(item or "").strip().lower()
for item in request.POST.getlist("allowed_services")
if str(item or "").strip()
]
allowed_channels = self._channels_map_from_post(request)
policy, _ = CommandSecurityPolicy.objects.get_or_create(
user=request.user,
scope_key=scope_key,
)
policy.allowed_services = allowed_services
policy.allowed_channels = allowed_channels
if global_overrides["scope_enabled"] == "per_scope":
policy.enabled = _to_bool(request.POST.get("policy_enabled"), True)
if global_overrides["require_omemo"] == "per_scope" and not bool(
security_settings.require_omemo
):
policy.require_omemo = _to_bool(
request.POST.get("policy_require_omemo"), False
)
if global_overrides["require_trusted_fingerprint"] == "per_scope":
policy.require_trusted_omemo_fingerprint = _to_bool(
request.POST.get("policy_require_trusted_fingerprint"),
False,
)
policy.save(
update_fields=[
"enabled",
"require_omemo",
"require_trusted_omemo_fingerprint",
"allowed_services",
"allowed_channels",
"updated_at",
]
)
return redirect_to
def get(self, request):
show_encryption = self._show_encryption()
show_permission = self._show_permission()
xmpp_state = transport.get_runtime_state("xmpp") if show_encryption else {}
omemo_row = None
if show_encryption:
try:
omemo_row = UserXmppOmemoState.objects.get(user=request.user)
except UserXmppOmemoState.DoesNotExist:
omemo_row = None
discovered_omemo_keys = self._omemo_discovered_keys(
request, xmpp_state, omemo_row
)
security_settings = self._security_settings(request)
sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
omemo_client_fingerprint = _latest_client_omemo_fingerprint(omemo_row)
omemo_client_key_info = _parse_omemo_client_key(
getattr(omemo_row, "latest_client_key", "") if omemo_row else ""
)
omemo_plan = []
if show_encryption:
omemo_plan = [
{
"label": "Component OMEMO active",
"done": bool(xmpp_state.get("omemo_enabled")),
"hint": "The gateway's OMEMO plugin must be loaded and initialised.",
},
{
"label": "OMEMO observed from your client",
"done": omemo_row is not None and omemo_row.status == "detected",
"hint": "Send any message with OMEMO enabled in your XMPP client.",
},
{
"label": "Client key on file",
"done": bool(getattr(omemo_row, "latest_client_key", "")),
"hint": "A device key (sid/rid) must be recorded from your client.",
},
{
"label": "Encryption required",
"done": security_settings.require_omemo,
"hint": "Enable 'Require OMEMO encryption' in Security Policy above to enforce this policy.",
},
]
return render(
request,
self.template_name,
{
"xmpp_state": xmpp_state,
"omemo_row": omemo_row,
"discovered_omemo_keys": discovered_omemo_keys,
"security_settings": security_settings,
"global_override": self._global_override_payload(request),
"policy_services": self.POLICY_SERVICES,
"policy_rows": self._scope_rows(request),
"policy_groups": self._grouped_scope_rows(request),
"sender_jid": sender_jid,
"omemo_client_fingerprint": omemo_client_fingerprint,
"omemo_client_key_info": omemo_client_key_info,
"omemo_plan": omemo_plan,
"show_encryption": show_encryption,
"show_permission": show_permission,
},
)
class AccessibilitySettings(LoginRequiredMixin, View):
template_name = "pages/accessibility-settings.html"
def _row(self, request):
row, _ = UserAccessibilitySettings.objects.get_or_create(user=request.user)
return row
def get(self, request):
return render(
request,
self.template_name,
{
"accessibility_settings": self._row(request),
},
)
def post(self, request):
row = self._row(request)
row.disable_animations = _to_bool(request.POST.get("disable_animations"), False)
row.save(update_fields=["disable_animations", "updated_at"])
return HttpResponseRedirect(reverse("accessibility_settings"))
class _SettingsCategoryPage(LoginRequiredMixin, View):
template_name = "pages/settings-category.html"
category_key = "general"
category_title = "General"
category_description = ""
tabs = ()
def _tab_rows(self):
current_path = str(getattr(self.request, "path", "") or "")
rows = []
for label, href in self.tabs:
rows.append(
{
"label": label,
"href": href,
"active": current_path == href,
}
)
return rows
def get(self, request):
return render(
request,
self.template_name,
{
"category_key": self.category_key,
"category_title": self.category_title,
"category_description": self.category_description,
"category_tabs": self._tab_rows(),
},
)
class AISettingsPage(LoginRequiredMixin, View):
def get(self, request):
return HttpResponseRedirect(reverse("ai_models"))
class ModulesSettingsPage(_SettingsCategoryPage):
def get(self, request):
return HttpResponseRedirect(reverse("command_routing"))