from enum import Enum, auto from typing import Any from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ from django_stubs_ext import StrPromise from typing_extensions import override class ErrorCode(Enum): BAD_REQUEST = auto() # Generic name, from the name of HTTP 400. REQUEST_VARIABLE_MISSING = auto() REQUEST_VARIABLE_INVALID = auto() INVALID_JSON = auto() BAD_IMAGE = auto() REALM_UPLOAD_QUOTA = auto() BAD_NARROW = auto() CANNOT_DEACTIVATE_LAST_USER = auto() MISSING_HTTP_EVENT_HEADER = auto() STREAM_DOES_NOT_EXIST = auto() UNAUTHORIZED_PRINCIPAL = auto() UNSUPPORTED_WEBHOOK_EVENT_TYPE = auto() ANOMALOUS_WEBHOOK_PAYLOAD = auto() BAD_EVENT_QUEUE_ID = auto() CSRF_FAILED = auto() INVITATION_FAILED = auto() INVALID_ZULIP_SERVER = auto() INVALID_PUSH_DEVICE_TOKEN = auto() INVALID_REMOTE_PUSH_DEVICE_TOKEN = auto() INVALID_MARKDOWN_INCLUDE_STATEMENT = auto() REQUEST_CONFUSING_VAR = auto() INVALID_API_KEY = auto() INVALID_ZOOM_TOKEN = auto() UNAUTHENTICATED_USER = auto() NONEXISTENT_SUBDOMAIN = auto() RATE_LIMIT_HIT = auto() USER_DEACTIVATED = auto() REALM_DEACTIVATED = auto() REMOTE_SERVER_DEACTIVATED = auto() PASSWORD_AUTH_DISABLED = auto() PASSWORD_RESET_REQUIRED = auto() AUTHENTICATION_FAILED = auto() UNAUTHORIZED = auto() REQUEST_TIMEOUT = auto() MOVE_MESSAGES_TIME_LIMIT_EXCEEDED = auto() REACTION_ALREADY_EXISTS = auto() REACTION_DOES_NOT_EXIST = auto() SERVER_NOT_READY = auto() MISSING_REMOTE_REALM = auto() TOPIC_WILDCARD_MENTION_NOT_ALLOWED = auto() STREAM_WILDCARD_MENTION_NOT_ALLOWED = auto() REMOTE_BILLING_UNAUTHENTICATED_USER = auto() REMOTE_REALM_SERVER_MISMATCH_ERROR = auto() PUSH_NOTIFICATIONS_DISALLOWED = auto() EXPECTATION_MISMATCH = auto() SYSTEM_GROUP_REQUIRED = auto() CANNOT_DEACTIVATE_GROUP_IN_USE = auto() class JsonableError(Exception): """A standardized error format we can turn into a nice JSON HTTP response. This class can be invoked in a couple ways. * Easiest, but completely machine-unreadable: raise JsonableError(_("No such widget: {}").format(widget_name)) The message may be passed through to clients and shown to a user, so translation is required. Because the text will vary depending on the user's language, it's not possible for code to distinguish this error from others in a non-buggy way. * Fully machine-readable, with an error code and structured data: class NoSuchWidgetError(JsonableError): code = ErrorCode.NO_SUCH_WIDGET data_fields = ['widget_name'] def __init__(self, widget_name: str) -> None: self.widget_name: str = widget_name @staticmethod def msg_format() -> str: return _("No such widget: {widget_name}") raise NoSuchWidgetError(widget_name) Now both server and client code see a `widget_name` attribute and an error code. Subclasses may also override `http_status_code`. """ # Override this in subclasses, as needed. code: ErrorCode = ErrorCode.BAD_REQUEST # Override this in subclasses if providing structured data. data_fields: list[str] = [] # Optionally override this in subclasses to return a different HTTP status, # like 403 or 404. http_status_code: int = 400 def __init__(self, msg: str | StrPromise) -> None: # `_msg` is an implementation detail of `JsonableError` itself. self._msg = msg @staticmethod def msg_format() -> str: """Override in subclasses. Gets the items in `data_fields` as format args. This should return (a translation of) a string literal. The reason it's not simply a class attribute is to allow translation to work. """ # Secretly this gets one more format arg not in `data_fields`: `_msg`. # That's for the sake of the `JsonableError` base logic itself, for # the simplest form of use where we just get a plain message string # at construction time. return "{_msg}" @property def extra_headers(self) -> dict[str, Any]: return {} # # Infrastructure -- not intended to be overridden in subclasses. # @property def msg(self) -> str: format_data = dict( ((f, getattr(self, f)) for f in self.data_fields), _msg=getattr(self, "_msg", None) ) return self.msg_format().format(**format_data) @property def data(self) -> dict[str, Any]: return dict(((f, getattr(self, f)) for f in self.data_fields), code=self.code.name) @override def __str__(self) -> str: return self.msg class UnauthorizedError(JsonableError): code: ErrorCode = ErrorCode.UNAUTHORIZED http_status_code: int = 401 def __init__(self, msg: str | None = None, www_authenticate: str | None = None) -> None: if msg is None: msg = _("Not logged in: API authentication or user session required") super().__init__(msg) if www_authenticate is None: self.www_authenticate = 'Basic realm="zulip"' elif www_authenticate == "session": self.www_authenticate = 'Session realm="zulip"' else: raise AssertionError("Invalid www_authenticate value!") @property @override def extra_headers(self) -> dict[str, Any]: extra_headers_dict = super().extra_headers extra_headers_dict["WWW-Authenticate"] = self.www_authenticate return extra_headers_dict class StreamDoesNotExistError(JsonableError): code = ErrorCode.STREAM_DOES_NOT_EXIST data_fields = ["stream"] def __init__(self, stream: str) -> None: self.stream = stream @staticmethod @override def msg_format() -> str: return _("Channel '{stream}' does not exist") class StreamWithIDDoesNotExistError(JsonableError): code = ErrorCode.STREAM_DOES_NOT_EXIST data_fields = ["stream_id"] def __init__(self, stream_id: int) -> None: self.stream_id = stream_id @staticmethod @override def msg_format() -> str: return _("Channel with ID '{stream_id}' does not exist") class IncompatibleParametersError(JsonableError): data_fields = ["parameters"] def __init__(self, parameters: list[str]) -> None: self.parameters = ", ".join(parameters) @staticmethod @override def msg_format() -> str: return _("Unsupported parameter combination: {parameters}") class CannotDeactivateLastUserError(JsonableError): code = ErrorCode.CANNOT_DEACTIVATE_LAST_USER data_fields = ["is_last_owner", "entity"] def __init__(self, is_last_owner: bool) -> None: self.is_last_owner = is_last_owner self.entity = _("organization owner") if is_last_owner else _("user") @staticmethod @override def msg_format() -> str: return _("Cannot deactivate the only {entity}.") class InvalidMarkdownIncludeStatementError(JsonableError): code = ErrorCode.INVALID_MARKDOWN_INCLUDE_STATEMENT data_fields = ["include_statement"] def __init__(self, include_statement: str) -> None: self.include_statement = include_statement @staticmethod @override def msg_format() -> str: return _("Invalid Markdown include statement: {include_statement}") class RateLimitedError(JsonableError): code = ErrorCode.RATE_LIMIT_HIT http_status_code = 429 def __init__(self, secs_to_freedom: float | None = None) -> None: self.secs_to_freedom = secs_to_freedom @staticmethod @override def msg_format() -> str: return _("API usage exceeded rate limit") @property @override def extra_headers(self) -> dict[str, Any]: extra_headers_dict = super().extra_headers if self.secs_to_freedom is not None: extra_headers_dict["Retry-After"] = self.secs_to_freedom return extra_headers_dict @property @override def data(self) -> dict[str, Any]: data_dict = super().data data_dict["retry-after"] = self.secs_to_freedom return data_dict class InvalidJSONError(JsonableError): code = ErrorCode.INVALID_JSON @staticmethod @override def msg_format() -> str: return _("Malformed JSON") class OrganizationMemberRequiredError(JsonableError): code: ErrorCode = ErrorCode.UNAUTHORIZED_PRINCIPAL def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Must be an organization member") class OrganizationAdministratorRequiredError(JsonableError): code: ErrorCode = ErrorCode.UNAUTHORIZED_PRINCIPAL def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Must be an organization administrator") class OrganizationOwnerRequiredError(JsonableError): code: ErrorCode = ErrorCode.UNAUTHORIZED_PRINCIPAL def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Must be an organization owner") class AuthenticationFailedError(JsonableError): # Generic class for authentication failures code: ErrorCode = ErrorCode.AUTHENTICATION_FAILED http_status_code = 401 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Your username or password is incorrect") class UserDeactivatedError(AuthenticationFailedError): code: ErrorCode = ErrorCode.USER_DEACTIVATED @staticmethod @override def msg_format() -> str: return _("Account is deactivated") class RealmDeactivatedError(AuthenticationFailedError): code: ErrorCode = ErrorCode.REALM_DEACTIVATED @staticmethod @override def msg_format() -> str: return _("This organization has been deactivated") class RemoteServerDeactivatedError(AuthenticationFailedError): code: ErrorCode = ErrorCode.REALM_DEACTIVATED @staticmethod @override def msg_format() -> str: return _( "The mobile push notification service registration for your server has been deactivated" ) class PasswordAuthDisabledError(AuthenticationFailedError): code: ErrorCode = ErrorCode.PASSWORD_AUTH_DISABLED @staticmethod @override def msg_format() -> str: return _("Password authentication is disabled in this organization") class PasswordResetRequiredError(AuthenticationFailedError): code: ErrorCode = ErrorCode.PASSWORD_RESET_REQUIRED @staticmethod @override def msg_format() -> str: return _("Your password has been disabled and needs to be reset") class MarkdownRenderingError(Exception): pass class InvalidAPIKeyError(JsonableError): code = ErrorCode.INVALID_API_KEY http_status_code = 401 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Invalid API key") class InvalidAPIKeyFormatError(InvalidAPIKeyError): @staticmethod @override def msg_format() -> str: return _("Malformed API key") class WebhookError(JsonableError): """ Intended as a generic exception raised by specific webhook integrations. This class is subclassed by more specific exceptions such as UnsupportedWebhookEventTypeError and AnomalousWebhookPayloadError. """ data_fields = ["webhook_name"] def __init__(self) -> None: # webhook_name is often set by decorators such as webhook_view # in zerver/decorator.py self.webhook_name = "(unknown)" class UnsupportedWebhookEventTypeError(WebhookError): """Intended as an exception for event formats that we know the third-party service generates but which Zulip doesn't support / generate a message for. Exceptions where we cannot parse the event type, possibly because the event isn't actually from the service in question, should raise AnomalousWebhookPayloadError. """ code = ErrorCode.UNSUPPORTED_WEBHOOK_EVENT_TYPE http_status_code = 200 data_fields = ["webhook_name", "event_type"] def __init__(self, event_type: str | None) -> None: super().__init__() self.event_type = event_type @staticmethod @override def msg_format() -> str: return _( "The '{event_type}' event isn't currently supported by the {webhook_name} webhook; ignoring" ) class AnomalousWebhookPayloadError(WebhookError): """Intended as an exception for incoming webhook requests that we cannot recognize as having been generated by the service in question. (E.g. because someone pointed a Jira server at the GitHub integration URL). If we can parse the event but don't support it, use UnsupportedWebhookEventTypeError. """ code = ErrorCode.ANOMALOUS_WEBHOOK_PAYLOAD @staticmethod @override def msg_format() -> str: return _("Unable to parse request: Did {webhook_name} generate this event?") class MissingAuthenticationError(JsonableError): code = ErrorCode.UNAUTHENTICATED_USER http_status_code = 401 def __init__(self) -> None: pass # No msg_format is defined since this exception is caught and # converted into json_unauthorized in Zulip's middleware. class RemoteBillingAuthenticationError(JsonableError): # We want this as a distinct class from MissingAuthenticationError, # as we don't want the json_unauthorized conversion mechanism to apply # to this. code = ErrorCode.REMOTE_BILLING_UNAUTHENTICATED_USER http_status_code = 401 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("User not authenticated") class InvalidSubdomainError(JsonableError): code = ErrorCode.NONEXISTENT_SUBDOMAIN http_status_code = 404 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Invalid subdomain") class ZephyrMessageAlreadySentError(Exception): def __init__(self, message_id: int) -> None: self.message_id = message_id class InvitationError(JsonableError): code = ErrorCode.INVITATION_FAILED data_fields = [ "errors", "sent_invitations", "license_limit_reached", "daily_limit_reached", ] def __init__( self, msg: str, errors: list[tuple[str, str, bool]], sent_invitations: bool, license_limit_reached: bool = False, daily_limit_reached: bool = False, ) -> None: self._msg: str = msg self.errors: list[tuple[str, str, bool]] = errors self.sent_invitations: bool = sent_invitations self.license_limit_reached: bool = license_limit_reached self.daily_limit_reached: bool = daily_limit_reached class DirectMessageInitiationError(JsonableError): def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("You do not have permission to initiate direct message conversations.") class DirectMessagePermissionError(JsonableError): def __init__(self, is_nobody_group: bool) -> None: if is_nobody_group: msg = _("Direct messages are disabled in this organization.") else: msg = _("This conversation does not include any users who can authorize it.") super().__init__(msg) class AccessDeniedError(JsonableError): http_status_code = 403 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Access denied") class ResourceNotFoundError(JsonableError): http_status_code = 404 class ValidationFailureError(JsonableError): # This class translations a Django ValidationError into a # Zulip-style JsonableError, sending back just the first error for # consistency of API. data_fields = ["errors"] def __init__(self, error: ValidationError) -> None: super().__init__(error.messages[0]) self.errors = error.message_dict class MessageMoveError(JsonableError): code = ErrorCode.MOVE_MESSAGES_TIME_LIMIT_EXCEEDED data_fields = [ "first_message_id_allowed_to_move", "total_messages_in_topic", "total_messages_allowed_to_move", ] def __init__( self, first_message_id_allowed_to_move: int, total_messages_in_topic: int, total_messages_allowed_to_move: int, ) -> None: self.first_message_id_allowed_to_move = first_message_id_allowed_to_move self.total_messages_in_topic = total_messages_in_topic self.total_messages_allowed_to_move = total_messages_allowed_to_move @staticmethod @override def msg_format() -> str: return _( "You only have permission to move the {total_messages_allowed_to_move}/{total_messages_in_topic} most recent messages in this topic." ) class ReactionExistsError(JsonableError): code = ErrorCode.REACTION_ALREADY_EXISTS def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Reaction already exists.") class ReactionDoesNotExistError(JsonableError): code = ErrorCode.REACTION_DOES_NOT_EXIST def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Reaction doesn't exist.") class ApiParamValidationError(JsonableError): def __init__(self, msg: str, error_type: str) -> None: super().__init__(msg) self.error_type = error_type class ServerNotReadyError(JsonableError): code = ErrorCode.SERVER_NOT_READY http_status_code = 500 class RemoteRealmServerMismatchError(JsonableError): # nocoverage code = ErrorCode.REMOTE_REALM_SERVER_MISMATCH_ERROR http_status_code = 403 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _( "Your organization is registered to a different Zulip server. Please contact Zulip support for assistance in resolving this issue." ) class MissingRemoteRealmError(JsonableError): # nocoverage code: ErrorCode = ErrorCode.MISSING_REMOTE_REALM http_status_code = 403 def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("Organization not registered") class StreamWildcardMentionNotAllowedError(JsonableError): code: ErrorCode = ErrorCode.STREAM_WILDCARD_MENTION_NOT_ALLOWED def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("You do not have permission to use channel wildcard mentions in this channel.") class TopicWildcardMentionNotAllowedError(JsonableError): code: ErrorCode = ErrorCode.TOPIC_WILDCARD_MENTION_NOT_ALLOWED def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("You do not have permission to use topic wildcard mentions in this topic.") class PreviousSettingValueMismatchedError(JsonableError): code: ErrorCode = ErrorCode.EXPECTATION_MISMATCH def __init__(self) -> None: pass @staticmethod @override def msg_format() -> str: return _("'old' value does not match the expected value.") class SystemGroupRequiredError(JsonableError): code = ErrorCode.SYSTEM_GROUP_REQUIRED data_fields = ["setting_name"] def __init__(self, setting_name: str) -> None: self.setting_name = setting_name @staticmethod @override def msg_format() -> str: return _("'{setting_name}' must be a system user group.") class IncompatibleParameterValuesError(JsonableError): data_fields = ["first_parameter", "second_parameter"] def __init__(self, first_parameter: str, second_parameter: str) -> None: self.first_parameter = first_parameter self.second_parameter = second_parameter @staticmethod @override def msg_format() -> str: return _("Incompatible values for '{first_parameter}' and '{second_parameter}'.") class CannotDeactivateGroupInUseError(JsonableError): code = ErrorCode.CANNOT_DEACTIVATE_GROUP_IN_USE data_fields = ["objections"] def __init__( self, objections: list[dict[str, Any]], ) -> None: self.objections = objections @staticmethod @override def msg_format() -> str: return _("Cannot deactivate user group in use.")