2021-05-17 20:26:48 +02:00
|
|
|
import fnmatch
|
2019-06-05 15:12:34 +02:00
|
|
|
import importlib
|
2020-11-24 21:26:05 +01:00
|
|
|
from datetime import datetime
|
2024-07-12 02:30:17 +02:00
|
|
|
from typing import Any, Callable, Optional, Union
|
2018-11-06 17:07:04 +01:00
|
|
|
from urllib.parse import unquote
|
|
|
|
|
2018-03-13 23:36:11 +01:00
|
|
|
from django.http import HttpRequest
|
2021-04-16 00:57:30 +02:00
|
|
|
from django.utils.translation import gettext as _
|
2023-08-12 08:26:51 +02:00
|
|
|
from pydantic import Json
|
2023-10-12 19:43:45 +02:00
|
|
|
from typing_extensions import Annotated, TypeAlias, override
|
2018-03-13 23:36:11 +01:00
|
|
|
|
2022-04-14 23:50:10 +02:00
|
|
|
from zerver.actions.message_send import (
|
2020-06-11 00:54:34 +02:00
|
|
|
check_send_private_message,
|
|
|
|
check_send_stream_message,
|
2021-06-30 14:15:27 +02:00
|
|
|
check_send_stream_message_by_id,
|
2020-06-11 00:54:34 +02:00
|
|
|
send_rate_limited_pm_notification_to_bot_owner,
|
|
|
|
)
|
2023-10-12 15:54:34 +02:00
|
|
|
from zerver.lib.exceptions import (
|
|
|
|
AnomalousWebhookPayloadError,
|
|
|
|
ErrorCode,
|
|
|
|
JsonableError,
|
|
|
|
StreamDoesNotExistError,
|
|
|
|
)
|
2023-08-12 08:26:51 +02:00
|
|
|
from zerver.lib.request import RequestNotes
|
2018-04-24 20:22:38 +02:00
|
|
|
from zerver.lib.send_email import FromAddress
|
2020-12-13 21:52:38 +01:00
|
|
|
from zerver.lib.timestamp import timestamp_to_datetime
|
2023-08-12 08:26:51 +02:00
|
|
|
from zerver.lib.typed_endpoint import ApiParamConfig, typed_endpoint
|
2019-02-02 23:53:55 +01:00
|
|
|
from zerver.models import UserProfile
|
2018-03-13 23:36:11 +01:00
|
|
|
|
2021-07-05 17:55:02 +02:00
|
|
|
MISSING_EVENT_HEADER_MESSAGE = """\
|
2018-04-24 20:22:38 +02:00
|
|
|
Hi there! Your bot {bot_name} just sent an HTTP request to {request_path} that
|
|
|
|
is missing the HTTP {header_name} header. Because this header is how
|
|
|
|
{integration_name} indicates the event type, this usually indicates a configuration
|
|
|
|
issue, where you either entered the URL for a different integration, or are running
|
|
|
|
an older version of the third-party service that doesn't provide that header.
|
|
|
|
Contact {support_email} if you need help debugging!
|
|
|
|
"""
|
|
|
|
|
2018-11-15 05:31:34 +01:00
|
|
|
INVALID_JSON_MESSAGE = """
|
2020-10-13 23:50:18 +02:00
|
|
|
Hi there! It looks like you tried to set up the Zulip {webhook_name} integration,
|
2018-11-15 05:31:34 +01:00
|
|
|
but didn't correctly configure the webhook to send data in the JSON format
|
|
|
|
that this integration expects!
|
|
|
|
"""
|
|
|
|
|
2021-05-06 16:02:36 +02:00
|
|
|
SETUP_MESSAGE_TEMPLATE = "{integration} webhook has been successfully configured"
|
|
|
|
SETUP_MESSAGE_USER_PART = " by {user_name}"
|
|
|
|
|
2023-08-12 08:26:51 +02:00
|
|
|
OptionalUserSpecifiedTopicStr: TypeAlias = Annotated[Optional[str], ApiParamConfig("topic")]
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-05-06 16:02:36 +02:00
|
|
|
def get_setup_webhook_message(integration: str, user_name: Optional[str] = None) -> str:
|
|
|
|
content = SETUP_MESSAGE_TEMPLATE.format(integration=integration)
|
|
|
|
if user_name:
|
|
|
|
content += SETUP_MESSAGE_USER_PART.format(user_name=user_name)
|
|
|
|
content = f"{content}."
|
|
|
|
return content
|
|
|
|
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
def notify_bot_owner_about_invalid_json(
|
|
|
|
user_profile: UserProfile, webhook_client_name: str
|
|
|
|
) -> None:
|
2018-11-15 05:31:34 +01:00
|
|
|
send_rate_limited_pm_notification_to_bot_owner(
|
2021-02-12 08:19:30 +01:00
|
|
|
user_profile,
|
|
|
|
user_profile.realm,
|
python: Use trailing commas consistently.
Automatically generated by the following script, based on the output
of lint with flake8-comma:
import re
import sys
last_filename = None
last_row = None
lines = []
for msg in sys.stdin:
m = re.match(
r"\x1b\[35mflake8 \|\x1b\[0m \x1b\[1;31m(.+):(\d+):(\d+): (\w+)", msg
)
if m:
filename, row_str, col_str, err = m.groups()
row, col = int(row_str), int(col_str)
if filename == last_filename:
assert last_row != row
else:
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
with open(filename) as f:
lines = f.readlines()
last_filename = filename
last_row = row
line = lines[row - 1]
if err in ["C812", "C815"]:
lines[row - 1] = line[: col - 1] + "," + line[col - 1 :]
elif err in ["C819"]:
assert line[col - 2] == ","
lines[row - 1] = line[: col - 2] + line[col - 1 :].lstrip(" ")
if last_filename is not None:
with open(last_filename, "w") as f:
f.writelines(lines)
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-10 05:23:40 +02:00
|
|
|
INVALID_JSON_MESSAGE.format(webhook_name=webhook_client_name).strip(),
|
2018-11-15 05:31:34 +01:00
|
|
|
)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-10-12 15:54:34 +02:00
|
|
|
class MissingHTTPEventHeaderError(AnomalousWebhookPayloadError):
|
2018-04-24 20:22:38 +02:00
|
|
|
code = ErrorCode.MISSING_HTTP_EVENT_HEADER
|
2021-02-12 08:20:45 +01:00
|
|
|
data_fields = ["header"]
|
2018-04-24 20:22:38 +02:00
|
|
|
|
2018-05-11 01:40:23 +02:00
|
|
|
def __init__(self, header: str) -> None:
|
2018-04-24 20:22:38 +02:00
|
|
|
self.header = header
|
|
|
|
|
|
|
|
@staticmethod
|
2023-10-12 19:43:45 +02:00
|
|
|
@override
|
2018-04-24 20:22:38 +02:00
|
|
|
def msg_format() -> str:
|
|
|
|
return _("Missing the HTTP event header '{header}'")
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-08-12 08:26:51 +02:00
|
|
|
@typed_endpoint
|
2018-03-13 23:36:11 +01:00
|
|
|
def check_send_webhook_message(
|
2021-02-12 08:19:30 +01:00
|
|
|
request: HttpRequest,
|
|
|
|
user_profile: UserProfile,
|
|
|
|
topic: str,
|
|
|
|
body: str,
|
2021-05-17 20:26:48 +02:00
|
|
|
complete_event_type: Optional[str] = None,
|
2023-08-12 08:26:51 +02:00
|
|
|
*,
|
|
|
|
stream: Optional[str] = None,
|
|
|
|
user_specified_topic: OptionalUserSpecifiedTopicStr = None,
|
2024-07-12 02:30:17 +02:00
|
|
|
only_events: Optional[Json[list[str]]] = None,
|
|
|
|
exclude_events: Optional[Json[list[str]]] = None,
|
2021-02-12 08:19:30 +01:00
|
|
|
unquote_url_parameters: bool = False,
|
2018-03-13 23:36:11 +01:00
|
|
|
) -> None:
|
2023-01-18 02:59:37 +01:00
|
|
|
if complete_event_type is not None and (
|
2021-05-17 20:26:48 +02:00
|
|
|
# Here, we implement Zulip's generic support for filtering
|
|
|
|
# events sent by the third-party service.
|
|
|
|
#
|
|
|
|
# If complete_event_type is passed to this function, we will check the event
|
|
|
|
# type against user configured lists of only_events and exclude events.
|
|
|
|
# If the event does not satisfy the configuration, the function will return
|
|
|
|
# without sending any messages.
|
|
|
|
#
|
|
|
|
# We match items in only_events and exclude_events using Unix
|
|
|
|
# shell-style wildcards.
|
2023-01-18 02:59:37 +01:00
|
|
|
(
|
2021-05-17 20:26:48 +02:00
|
|
|
only_events is not None
|
2021-12-30 15:51:50 +01:00
|
|
|
and all(not fnmatch.fnmatch(complete_event_type, pattern) for pattern in only_events)
|
2023-01-18 02:59:37 +01:00
|
|
|
)
|
|
|
|
or (
|
2021-05-17 20:26:48 +02:00
|
|
|
exclude_events is not None
|
2021-12-30 15:51:50 +01:00
|
|
|
and any(fnmatch.fnmatch(complete_event_type, pattern) for pattern in exclude_events)
|
2023-01-18 02:59:37 +01:00
|
|
|
)
|
|
|
|
):
|
|
|
|
return
|
2018-03-13 23:36:11 +01:00
|
|
|
|
2021-08-21 19:24:20 +02:00
|
|
|
client = RequestNotes.get_notes(request).client
|
2021-07-09 18:10:51 +02:00
|
|
|
assert client is not None
|
2018-03-13 23:36:11 +01:00
|
|
|
if stream is None:
|
|
|
|
assert user_profile.bot_owner is not None
|
2021-07-09 18:10:51 +02:00
|
|
|
check_send_private_message(user_profile, client, user_profile.bot_owner, body)
|
2018-03-13 23:36:11 +01:00
|
|
|
else:
|
2021-05-10 07:02:14 +02:00
|
|
|
# Some third-party websites (such as Atlassian's Jira), tend to
|
2018-11-06 17:07:04 +01:00
|
|
|
# double escape their URLs in a manner that escaped space characters
|
|
|
|
# (%20) are never properly decoded. We work around that by making sure
|
2018-12-04 01:59:39 +01:00
|
|
|
# that the URL parameters are decoded on our end.
|
2022-06-01 00:09:19 +02:00
|
|
|
if unquote_url_parameters:
|
2018-11-06 17:07:04 +01:00
|
|
|
stream = unquote(stream)
|
|
|
|
|
2018-03-13 23:36:11 +01:00
|
|
|
if user_specified_topic is not None:
|
|
|
|
topic = user_specified_topic
|
2018-12-04 01:59:39 +01:00
|
|
|
if unquote_url_parameters:
|
|
|
|
topic = unquote(topic)
|
2018-03-22 21:43:28 +01:00
|
|
|
|
|
|
|
try:
|
2021-06-30 14:15:27 +02:00
|
|
|
if stream.isdecimal():
|
2021-07-09 18:10:51 +02:00
|
|
|
check_send_stream_message_by_id(user_profile, client, int(stream), topic, body)
|
2021-06-30 14:15:27 +02:00
|
|
|
else:
|
2021-07-09 18:10:51 +02:00
|
|
|
check_send_stream_message(user_profile, client, stream, topic, body)
|
2018-03-22 21:43:28 +01:00
|
|
|
except StreamDoesNotExistError:
|
2023-06-19 16:34:52 +02:00
|
|
|
# A direct message will be sent to the bot_owner by check_message,
|
|
|
|
# notifying that the webhook bot just tried to send a message to a
|
|
|
|
# non-existent stream, so we don't need to re-raise it since it
|
|
|
|
# clutters up webhook-errors.log
|
2018-03-22 21:43:28 +01:00
|
|
|
pass
|
2018-04-24 20:22:38 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def standardize_headers(input_headers: Union[None, dict[str, Any]]) -> dict[str, str]:
|
2021-02-12 08:19:30 +01:00
|
|
|
"""This method can be used to standardize a dictionary of headers with
|
2019-06-21 04:41:30 +02:00
|
|
|
the standard format that Django expects. For reference, refer to:
|
2024-05-24 16:57:31 +02:00
|
|
|
https://docs.djangoproject.com/en/5.0/ref/request-response/#django.http.HttpRequest.headers
|
2019-06-21 04:41:30 +02:00
|
|
|
|
|
|
|
NOTE: Historically, Django's headers were not case-insensitive. We're still
|
|
|
|
capitalizing our headers to make it easier to compare/search later if required.
|
|
|
|
"""
|
|
|
|
canonical_headers = {}
|
|
|
|
|
|
|
|
if not input_headers:
|
2019-06-05 15:12:34 +02:00
|
|
|
return {}
|
2019-06-21 04:41:30 +02:00
|
|
|
|
|
|
|
for raw_header in input_headers:
|
|
|
|
polished_header = raw_header.upper().replace("-", "_")
|
2023-01-18 02:59:37 +01:00
|
|
|
if polished_header not in [
|
|
|
|
"CONTENT_TYPE",
|
|
|
|
"CONTENT_LENGTH",
|
|
|
|
] and not polished_header.startswith("HTTP_"):
|
|
|
|
polished_header = "HTTP_" + polished_header
|
2019-06-21 04:41:30 +02:00
|
|
|
canonical_headers[polished_header] = str(input_headers[raw_header])
|
|
|
|
|
|
|
|
return canonical_headers
|
2019-06-05 15:12:34 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
|
|
|
def validate_extract_webhook_http_header(
|
2023-10-06 00:12:22 +02:00
|
|
|
request: HttpRequest, header: str, integration_name: str
|
|
|
|
) -> str:
|
2021-07-24 20:37:35 +02:00
|
|
|
assert request.user.is_authenticated
|
|
|
|
|
2022-05-12 06:54:12 +02:00
|
|
|
extracted_header = request.headers.get(header)
|
2023-10-06 00:12:22 +02:00
|
|
|
if extracted_header is None:
|
2018-04-24 20:22:38 +02:00
|
|
|
message_body = MISSING_EVENT_HEADER_MESSAGE.format(
|
|
|
|
bot_name=request.user.full_name,
|
|
|
|
request_path=request.path,
|
|
|
|
header_name=header,
|
|
|
|
integration_name=integration_name,
|
|
|
|
support_email=FromAddress.SUPPORT,
|
|
|
|
)
|
|
|
|
send_rate_limited_pm_notification_to_bot_owner(
|
2021-02-12 08:19:30 +01:00
|
|
|
request.user, request.user.realm, message_body
|
|
|
|
)
|
2018-04-24 20:22:38 +02:00
|
|
|
|
2022-11-17 09:30:48 +01:00
|
|
|
raise MissingHTTPEventHeaderError(header)
|
2018-04-24 20:22:38 +02:00
|
|
|
|
|
|
|
return extracted_header
|
2019-06-05 15:12:34 +02:00
|
|
|
|
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_fixture_http_headers(integration_name: str, fixture_name: str) -> dict["str", "str"]:
|
2019-06-05 15:12:34 +02:00
|
|
|
"""For integrations that require custom HTTP headers for some (or all)
|
|
|
|
of their test fixtures, this method will call a specially named
|
|
|
|
function from the target integration module to determine what set
|
|
|
|
of HTTP headers goes with the given test fixture.
|
|
|
|
"""
|
2020-06-10 06:40:53 +02:00
|
|
|
view_module_name = f"zerver.webhooks.{integration_name}.view"
|
2019-06-05 15:12:34 +02:00
|
|
|
try:
|
|
|
|
# TODO: We may want to migrate to a more explicit registration
|
|
|
|
# strategy for this behavior rather than a try/except import.
|
2019-06-22 06:57:40 +02:00
|
|
|
view_module = importlib.import_module(view_module_name)
|
2022-10-08 06:10:17 +02:00
|
|
|
fixture_to_headers = view_module.fixture_to_headers
|
2019-06-22 06:57:40 +02:00
|
|
|
except (ImportError, AttributeError):
|
2019-06-05 15:12:34 +02:00
|
|
|
return {}
|
|
|
|
return fixture_to_headers(fixture_name)
|
2019-06-21 00:24:23 +02:00
|
|
|
|
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def get_http_headers_from_filename(http_header_key: str) -> Callable[[str], dict[str, str]]:
|
2019-06-21 00:24:23 +02:00
|
|
|
"""If an integration requires an event type kind of HTTP header which can
|
|
|
|
be easily (statically) determined, then name the fixtures in the format
|
|
|
|
of "header_value__other_details" or even "header_value" and the use this
|
|
|
|
method in the headers.py file for the integration."""
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2024-07-12 02:30:17 +02:00
|
|
|
def fixture_to_headers(filename: str) -> dict[str, str]:
|
2021-02-12 08:20:45 +01:00
|
|
|
if "__" in filename:
|
2019-06-21 00:24:23 +02:00
|
|
|
event_type = filename.split("__")[0]
|
|
|
|
else:
|
|
|
|
event_type = filename
|
|
|
|
return {http_header_key: event_type}
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2019-06-21 00:24:23 +02:00
|
|
|
return fixture_to_headers
|
2020-11-24 21:26:05 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-11-24 21:26:05 +01:00
|
|
|
def unix_milliseconds_to_timestamp(milliseconds: Any, webhook: str) -> datetime:
|
|
|
|
"""If an integration requires time input in unix milliseconds, this helper
|
|
|
|
checks to ensure correct type and will catch any errors related to type or
|
|
|
|
value and raise a JsonableError.
|
|
|
|
Returns a datetime representing the time."""
|
|
|
|
try:
|
|
|
|
# timestamps are in milliseconds so divide by 1000
|
2020-12-13 21:52:38 +01:00
|
|
|
seconds = milliseconds / 1000
|
|
|
|
return timestamp_to_datetime(seconds)
|
|
|
|
except (ValueError, TypeError):
|
2023-07-17 22:40:33 +02:00
|
|
|
raise JsonableError(
|
|
|
|
_("The {webhook} webhook expects time in milliseconds.").format(webhook=webhook)
|
|
|
|
)
|