diff --git a/hc/api/models.py b/hc/api/models.py index 93c1b149..1bb7b726 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -2,6 +2,7 @@ import hashlib import json +import time import uuid from datetime import datetime, timedelta as td @@ -14,6 +15,7 @@ from django.urls import reverse from django.utils import timezone from hc.api import transports from hc.lib import emails +import requests STATUSES = ( ("up", "Up"), @@ -377,6 +379,37 @@ class Channel(models.Model): doc = json.loads(self.value) return doc.get("name") + def refresh_hipchat_access_token(self): + assert self.kind == "hipchat" + if not self.value.startswith("{"): + return # Don't have OAuth credentials + + doc = json.loads(self.value) + if time.time() < doc.get("expires_at", 0): + return # Current access token is still valid + + url = "https://api.hipchat.com/v2/oauth/token" + auth = (doc["oauthId"], doc["oauthSecret"]) + r = requests.post(url, auth=auth, data={ + "grant_type": "client_credentials", + "scope": "send_notification" + }) + + doc.update(r.json()) + doc["expires_at"] = int(time.time()) + doc["expires_in"] - 300 + self.value = json.dumps(doc) + self.save() + + @property + def hipchat_webhook_url(self): + assert self.kind == "hipchat" + if not self.value.startswith("{"): + return self.value + + doc = json.loads(self.value) + tmpl = "https://api.hipchat.com/v2/room/%s/notification?auth_token=%s" + return tmpl % (doc["roomId"], doc.get("access_token")) + def latest_notification(self): return Notification.objects.filter(channel=self).latest() diff --git a/hc/api/tests/test_channel_model.py b/hc/api/tests/test_channel_model.py new file mode 100644 index 00000000..6f5c6130 --- /dev/null +++ b/hc/api/tests/test_channel_model.py @@ -0,0 +1,21 @@ +import json + +from hc.api.models import Channel +from hc.test import BaseTestCase +from mock import patch + + +class ChannelModelTestCase(BaseTestCase): + + @patch("hc.api.models.requests.post") + def test_it_refreshes_hipchat_access_token(self, mock_post): + mock_post.return_value.json.return_value = {"expires_in": 100} + + channel = Channel(kind="hipchat", user=self.alice, value=json.dumps({ + "oauthId": "foo", + "oauthSecret": "bar" + })) + + channel.refresh_hipchat_access_token() + self.assertTrue(mock_post.return_value.json.called) + self.assertTrue("expires_at" in channel.value) diff --git a/hc/api/transports.py b/hc/api/transports.py index fc3cdc78..0d5585e7 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -175,7 +175,9 @@ class HipChat(HttpTransport): "message": text, "color": "green" if check.status == "up" else "red", } - return self.post(self.channel.value, json=payload) + + self.channel.refresh_hipchat_access_token() + return self.post(self.channel.hipchat_webhook_url, json=payload) class OpsGenie(HttpTransport): diff --git a/hc/front/tests/test_add_hipchat.py b/hc/front/tests/test_add_hipchat.py index 86adafb7..c791de75 100644 --- a/hc/front/tests/test_add_hipchat.py +++ b/hc/front/tests/test_add_hipchat.py @@ -1,5 +1,9 @@ +import json + +from django.core import signing from hc.api.models import Channel from hc.test import BaseTestCase +from mock import patch class AddHipChatTestCase(BaseTestCase): @@ -10,29 +14,38 @@ class AddHipChatTestCase(BaseTestCase): r = self.client.get(self.url) self.assertContains(r, "appropriate HipChat room") - def test_it_works(self): - form = {"value": "http://example.org"} + def test_instructions_work_when_logged_out(self): + r = self.client.get(self.url) + self.assertContains(r, "Before adding HipChat integration, please") + def test_it_redirects_to_addons_install(self): self.client.login(username="alice@example.org", password="password") - r = self.client.post(self.url, form) - self.assertRedirects(r, "/integrations/") + r = self.client.post(self.url) + self.assertEqual(r.status_code, 302) - c = Channel.objects.get() - self.assertEqual(c.kind, "hipchat") - self.assertEqual(c.value, "http://example.org") + def test_it_returns_capabilities(self): + r = self.client.get("/integrations/hipchat/capabilities/") + self.assertContains(r, "callbackUrl") - def test_it_rejects_bad_url(self): - form = {"value": "not an URL"} + @patch("hc.api.models.Channel.refresh_hipchat_access_token") + def test_callback_works(self, mock_refresh): + state = signing.TimestampSigner().sign("alice") + payload = json.dumps({"relayState": state, "foo": "foobar"}) - self.client.login(username="alice@example.org", password="password") - r = self.client.post(self.url, form) - self.assertContains(r, "Enter a valid URL") + r = self.client.post("/integrations/hipchat/callback/", payload, + content_type="application/json") - def test_it_trims_whitespace(self): - form = {"value": " http://example.org "} - - self.client.login(username="alice@example.org", password="password") - self.client.post(self.url, form) + self.assertEqual(r.status_code, 200) c = Channel.objects.get() - self.assertEqual(c.value, "http://example.org") + self.assertEqual(c.kind, "hipchat") + self.assertTrue("foobar" in c.value) + + @patch("hc.api.models.Channel.refresh_hipchat_access_token") + def test_callback_rejects_bad_signature(self, mock_refresh): + payload = json.dumps({"relayState": "alice:bad:sig", "foo": "foobar"}) + + r = self.client.post("/integrations/hipchat/callback/", payload, + content_type="application/json") + + self.assertEqual(r.status_code, 400) diff --git a/hc/front/urls.py b/hc/front/urls.py index 5e3f5fbe..21819aa1 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -19,6 +19,8 @@ channel_urls = [ url(r'^add_slack/$', views.add_slack, name="hc-add-slack"), url(r'^add_slack_btn/$', views.add_slack_btn, name="hc-add-slack-btn"), url(r'^add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"), + url(r'^hipchat/capabilities/$', views.hipchat_capabilities, name="hc-hipchat-capabilities"), + url(r'^hipchat/callback/$', views.hipchat_callback, name="hc-hipchat-callback"), url(r'^add_pushbullet/$', views.add_pushbullet, name="hc-add-pushbullet"), url(r'^add_discord/$', views.add_discord, name="hc-add-discord"), url(r'^add_pushover/$', views.add_pushover, name="hc-add-pushover"), diff --git a/hc/front/views.py b/hc/front/views.py index 81825015..31063be7 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -6,11 +6,12 @@ import json from croniter import croniter from django.conf import settings from django.contrib import messages +from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required from django.core import signing from django.db.models import Count from django.http import (Http404, HttpResponse, HttpResponseBadRequest, - HttpResponseForbidden) + HttpResponseForbidden, JsonResponse) from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.urls import reverse @@ -552,24 +553,69 @@ def add_slack_btn(request): return redirect("hc-channels") -@login_required def add_hipchat(request): if request.method == "POST": - form = AddUrlForm(request.POST) - if form.is_valid(): - channel = Channel(user=request.team.user, kind="hipchat") - channel.value = form.cleaned_data["value"] - channel.save() + username = request.team.user.username + state = signing.TimestampSigner().sign(username) + capabilities = settings.SITE_ROOT + reverse("hc-hipchat-capabilities") - channel.assign_all_checks() - return redirect("hc-channels") - else: - form = AddUrlForm() + url = "https://www.hipchat.com/addons/install?url=%s&relayState=%s" % \ + (capabilities, state) - ctx = {"page": "channels", "form": form} + return redirect(url) + + ctx = {"page": "channels"} return render(request, "integrations/add_hipchat.html", ctx) +def hipchat_capabilities(request): + return JsonResponse({ + "name": settings.SITE_NAME, + "description": "Get Notified When Your Cron Jobs Fail", + "key": "io.healthchecks.hipchat", + "links": { + "homepage": settings.SITE_ROOT, + "self": settings.SITE_ROOT + reverse("hc-hipchat-capabilities") + }, + "capabilities": { + "installable": { + "allowGlobal": False, + "allowRoom": True, + "callbackUrl": + settings.SITE_ROOT + reverse("hc-hipchat-callback"), + "installedUrl": + settings.SITE_ROOT + reverse("hc-channels") + "?added=hipchat" + }, + "hipchatApiConsumer": { + "scopes": [ + "send_notification" + ] + } + } + }) + + +@csrf_exempt +@require_POST +def hipchat_callback(request): + doc = json.loads(request.body.decode("utf-8")) + try: + signer = signing.TimestampSigner() + username = signer.unsign(doc.get("relayState"), max_age=300) + except signing.BadSignature: + return HttpResponseBadRequest() + + channel = Channel(kind="hipchat") + channel.user = User.objects.get(username=username) + channel.value = json.dumps(doc) + channel.save() + + channel.refresh_hipchat_access_token() + channel.assign_all_checks() + + return HttpResponse() + + @login_required def add_pushbullet(request): if settings.PUSHBULLET_CLIENT_ID is None: diff --git a/static/css/admin/channels.css b/static/css/admin/channels.css index 093bd503..04bbaca6 100644 --- a/static/css/admin/channels.css +++ b/static/css/admin/channels.css @@ -1,4 +1,4 @@ -.field-value { +.results .field-value { max-width: 400px; overflow: hidden; text-overflow: ellipsis; diff --git a/static/css/channels.css b/static/css/channels.css index b8286255..dca56541 100644 --- a/static/css/channels.css +++ b/static/css/channels.css @@ -5,6 +5,7 @@ .channels-table .channel-row > td { padding-top: 10px; padding-bottom: 10px; + vertical-align: middle; } .channels-table .value-cell { @@ -166,7 +167,6 @@ table.channels-table > tbody > tr > th { } .ai-guide-screenshot { - border: ; max-width: 100%; border: 6px solid #EEE; } diff --git a/static/img/integrations/setup_hipchat_1.png b/static/img/integrations/setup_hipchat_1.png index 024b98e1..511bf606 100644 Binary files a/static/img/integrations/setup_hipchat_1.png and b/static/img/integrations/setup_hipchat_1.png differ diff --git a/static/img/integrations/setup_hipchat_2.png b/static/img/integrations/setup_hipchat_2.png index 23abe33f..d43de71e 100644 Binary files a/static/img/integrations/setup_hipchat_2.png and b/static/img/integrations/setup_hipchat_2.png differ diff --git a/static/img/integrations/setup_hipchat_3.png b/static/img/integrations/setup_hipchat_3.png index 593d32e0..d3a31812 100644 Binary files a/static/img/integrations/setup_hipchat_3.png and b/static/img/integrations/setup_hipchat_3.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index e8dda0c6..713b25a7 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -89,6 +89,8 @@ user {% endif %} {{ ch.telegram_name }} + {% elif ch.kind == "hipchat" %} + {{ ch.hipchat_webhook_url }} {% else %} {{ ch.value }} {% endif %} diff --git a/templates/integrations/add_hipchat.html b/templates/integrations/add_hipchat.html index edb30b8e..5f8f7f80 100644 --- a/templates/integrations/add_hipchat.html +++ b/templates/integrations/add_hipchat.html @@ -9,17 +9,64 @@
If your team uses HipChat, - you can set up {% site_name %} to post status updates directly to an - appropriate HipChat room.
+If your team uses HipChat, + you can set up {% site_name %} to post status updates directly to an + appropriate HipChat room.
+ ++ {% site_name %} is a free and + open source + service for monitoring your cron jobs, background processes and + scheduled tasks. Before adding HipChat integration, please log into + {% site_name %}:
+ ++ After {% if request.user.is_authenticated %}{% else %}logging in and{% endif %} + clicking on "Install HipChat Integration", you will be + asked to log into HipChat. +
- From the list of available integrations, select - Build Your Own. It's at the very top. -
+ 3- Give it a descriptive name - and click Create. + Next, HipChat will let you select the chat room + for receiving {% site_name %} notifications.
Copy the displayed URL and paste it down below.
-Save the integration, and it's done!
+ 4 ++ As the final step, HipChat will show you the permissions + requested by {% site_name %}. There's only one permission + needed–"Send Notification". After clicking on "Approve" + you will be redirected back to + "Integrations" page on {% site_name %} and see + the new integration! +
- healthchecks.io is a free and + {% site_name %} is a free and open source service for monitoring your cron jobs, background processes and scheduled tasks. Before adding Slack integration, please log into - {% site_root %}:
+ {% site_name %}: