|
3 | 3 |
|
4 | 4 | import hashlib |
5 | 5 | import json |
| 6 | +import secrets |
6 | 7 | import uuid |
7 | 8 |
|
8 | 9 | from django.conf import settings |
|
16 | 17 | from core import enums, models |
17 | 18 | from core.mda.rfc5322 import extract_base64_images_from_html |
18 | 19 |
|
| 20 | +# Base58 alphabet — no ambiguous characters (0, O, I, l) |
| 21 | +BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" |
| 22 | + |
| 23 | + |
| 24 | +def generate_base58_password(length=16): |
| 25 | + """Generate a password using base58 alphabet. 16 chars ≈ 94 bits of entropy.""" |
| 26 | + return "".join(secrets.choice(BASE58_ALPHABET) for _ in range(length)) |
| 27 | + |
19 | 28 |
|
20 | 29 | class CreateOnlyFieldsMixin: |
21 | 30 | """Mixin that makes specified fields read-only on update (when instance exists). |
@@ -1175,9 +1184,7 @@ def validate(self, attrs): |
1175 | 1184 |
|
1176 | 1185 | if metadata.get("type") == "personal": |
1177 | 1186 | local_part = attrs.get("local_part", "") |
1178 | | - denylist = getattr( |
1179 | | - settings, "MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL", [] |
1180 | | - ) |
| 1187 | + denylist = settings.MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL |
1181 | 1188 | lower_value = local_part.lower() |
1182 | 1189 | if any(lower_value == prefix.lower() for prefix in denylist): |
1183 | 1190 | raise serializers.ValidationError( |
@@ -1491,6 +1498,83 @@ class Meta: |
1491 | 1498 | ] |
1492 | 1499 | read_only_fields = ["id", "mailbox", "maildomain", "created_at", "updated_at"] |
1493 | 1500 |
|
| 1501 | + # Keys in settings that should be moved to encrypted_settings, per channel type. |
| 1502 | + ENCRYPTED_SETTINGS_KEYS = { |
| 1503 | + "client-bridge": ["password"], |
| 1504 | + } |
| 1505 | + |
| 1506 | + def _move_sensitive_settings(self, validated_data): |
| 1507 | + """Move sensitive keys from settings to encrypted_settings.""" |
| 1508 | + channel_type = validated_data.get("type") or ( |
| 1509 | + self.instance.type if self.instance else None |
| 1510 | + ) |
| 1511 | + keys_to_encrypt = self.ENCRYPTED_SETTINGS_KEYS.get(channel_type, []) |
| 1512 | + if not keys_to_encrypt: |
| 1513 | + return validated_data |
| 1514 | + |
| 1515 | + settings_data = validated_data.get("settings") |
| 1516 | + if not settings_data: |
| 1517 | + return validated_data |
| 1518 | + |
| 1519 | + extracted = { |
| 1520 | + key: settings_data.pop(key) |
| 1521 | + for key in keys_to_encrypt |
| 1522 | + if key in settings_data |
| 1523 | + } |
| 1524 | + if extracted: |
| 1525 | + existing = (self.instance.encrypted_settings or {}) if self.instance else {} |
| 1526 | + validated_data["encrypted_settings"] = {**existing, **extracted} |
| 1527 | + |
| 1528 | + return validated_data |
| 1529 | + |
| 1530 | + def create(self, validated_data): |
| 1531 | + validated_data = self._move_sensitive_settings(validated_data) |
| 1532 | + if validated_data.get("type") == "client-bridge": |
| 1533 | + password = generate_base58_password() |
| 1534 | + validated_data.setdefault("encrypted_settings", {}) |
| 1535 | + validated_data["encrypted_settings"]["password"] = password |
| 1536 | + # Remove any user-supplied password from settings |
| 1537 | + if "settings" in validated_data and "password" in ( |
| 1538 | + validated_data["settings"] or {} |
| 1539 | + ): |
| 1540 | + validated_data["settings"].pop("password") |
| 1541 | + # Set default role if not provided |
| 1542 | + validated_data.setdefault("settings", {}) |
| 1543 | + validated_data["settings"].setdefault("role", "sender") |
| 1544 | + # Validate role |
| 1545 | + role = validated_data["settings"]["role"] |
| 1546 | + if role not in enums.CLIENT_BRIDGE_ROLES: |
| 1547 | + raise serializers.ValidationError( |
| 1548 | + { |
| 1549 | + "settings": { |
| 1550 | + "role": f"Invalid role. Must be one of: {', '.join(enums.CLIENT_BRIDGE_ROLES)}" |
| 1551 | + } |
| 1552 | + } |
| 1553 | + ) |
| 1554 | + instance = super().create(validated_data) |
| 1555 | + # Stash password so the view can return it once |
| 1556 | + instance._generated_password = password # noqa: SLF001 # pylint: disable=protected-access |
| 1557 | + return instance |
| 1558 | + return super().create(validated_data) |
| 1559 | + |
| 1560 | + def update(self, instance, validated_data): |
| 1561 | + validated_data = self._move_sensitive_settings(validated_data) |
| 1562 | + channel_type = validated_data.get("type") or ( |
| 1563 | + instance.type if instance else None |
| 1564 | + ) |
| 1565 | + if channel_type == "client-bridge": |
| 1566 | + settings_data = validated_data.get("settings") or {} |
| 1567 | + if "role" in settings_data: |
| 1568 | + if settings_data["role"] not in enums.CLIENT_BRIDGE_ROLES: |
| 1569 | + raise serializers.ValidationError( |
| 1570 | + { |
| 1571 | + "settings": { |
| 1572 | + "role": f"Invalid role. Must be one of: {', '.join(enums.CLIENT_BRIDGE_ROLES)}" |
| 1573 | + } |
| 1574 | + } |
| 1575 | + ) |
| 1576 | + return super().update(instance, validated_data) |
| 1577 | + |
1494 | 1578 | def validate_settings(self, value): |
1495 | 1579 | """Validate settings, including tags if present.""" |
1496 | 1580 | if not value: |
@@ -1550,14 +1634,21 @@ def validate(self, attrs): |
1550 | 1634 | if self.context.get("mailbox"): |
1551 | 1635 | channel_type = attrs.get("type") |
1552 | 1636 | if channel_type: |
1553 | | - allowed_types = settings.FEATURE_MAILBOX_ADMIN_CHANNELS |
| 1637 | + allowed_types = list(settings.FEATURE_MAILBOX_ADMIN_CHANNELS) |
1554 | 1638 | if channel_type not in allowed_types: |
1555 | 1639 | raise serializers.ValidationError( |
1556 | 1640 | { |
1557 | 1641 | "type": f"Channel type '{channel_type}' is not authorized. " |
1558 | 1642 | f"Allowed types: {', '.join(allowed_types)}" |
1559 | 1643 | } |
1560 | 1644 | ) |
| 1645 | + if ( |
| 1646 | + channel_type == "client-bridge" |
| 1647 | + and not settings.FEATURE_CLIENTBRIDGE |
| 1648 | + ): |
| 1649 | + raise serializers.ValidationError( |
| 1650 | + {"type": "Client bridge feature is not enabled."} |
| 1651 | + ) |
1561 | 1652 | return attrs |
1562 | 1653 |
|
1563 | 1654 | mailbox = attrs.get("mailbox") |
|
0 commit comments