You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

481 lines
14 KiB

10 years ago
8 years ago
10 years ago
10 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
  1. from datetime import timedelta as td
  2. import time
  3. import uuid
  4. from django.conf import settings
  5. from django.db import connection
  6. from django.http import (
  7. HttpResponse,
  8. HttpResponseForbidden,
  9. HttpResponseNotFound,
  10. HttpResponseBadRequest,
  11. JsonResponse,
  12. )
  13. from django.shortcuts import get_object_or_404
  14. from django.utils import timezone
  15. from django.views.decorators.cache import never_cache
  16. from django.views.decorators.csrf import csrf_exempt
  17. from django.views.decorators.http import require_POST
  18. from hc.accounts.models import Profile
  19. from hc.api import schemas
  20. from hc.api.decorators import authorize, authorize_read, cors, validate_json
  21. from hc.api.forms import FlipsFiltersForm
  22. from hc.api.models import MAX_DELTA, Flip, Channel, Check, Notification, Ping
  23. from hc.lib.badges import check_signature, get_badge_svg
  24. class BadChannelException(Exception):
  25. pass
  26. @csrf_exempt
  27. @never_cache
  28. def ping(request, code, action="success"):
  29. check = get_object_or_404(Check, code=code)
  30. headers = request.META
  31. remote_addr = headers.get("HTTP_X_FORWARDED_FOR", headers["REMOTE_ADDR"])
  32. remote_addr = remote_addr.split(",")[0]
  33. scheme = headers.get("HTTP_X_FORWARDED_PROTO", "http")
  34. method = headers["REQUEST_METHOD"]
  35. ua = headers.get("HTTP_USER_AGENT", "")
  36. body = request.body.decode()
  37. if check.methods == "POST" and method != "POST":
  38. action = "ign"
  39. check.ping(remote_addr, scheme, method, ua, body, action)
  40. response = HttpResponse("OK")
  41. response["Access-Control-Allow-Origin"] = "*"
  42. return response
  43. def _lookup(project, spec):
  44. unique_fields = spec.get("unique", [])
  45. if unique_fields:
  46. existing_checks = Check.objects.filter(project=project)
  47. if "name" in unique_fields:
  48. existing_checks = existing_checks.filter(name=spec.get("name"))
  49. if "tags" in unique_fields:
  50. existing_checks = existing_checks.filter(tags=spec.get("tags"))
  51. if "timeout" in unique_fields:
  52. timeout = td(seconds=spec["timeout"])
  53. existing_checks = existing_checks.filter(timeout=timeout)
  54. if "grace" in unique_fields:
  55. grace = td(seconds=spec["grace"])
  56. existing_checks = existing_checks.filter(grace=grace)
  57. return existing_checks.first()
  58. def _update(check, spec):
  59. channels = set()
  60. # First, validate the supplied channel codes
  61. if "channels" in spec and spec["channels"] not in ("*", ""):
  62. q = Channel.objects.filter(project=check.project)
  63. for s in spec["channels"].split(","):
  64. try:
  65. code = uuid.UUID(s)
  66. except ValueError:
  67. raise BadChannelException("invalid channel identifier: %s" % s)
  68. try:
  69. channels.add(q.get(code=code))
  70. except Channel.DoesNotExist:
  71. raise BadChannelException("invalid channel identifier: %s" % s)
  72. if "name" in spec:
  73. check.name = spec["name"]
  74. if "tags" in spec:
  75. check.tags = spec["tags"]
  76. if "desc" in spec:
  77. check.desc = spec["desc"]
  78. if "manual_resume" in spec:
  79. check.manual_resume = spec["manual_resume"]
  80. if "timeout" in spec and "schedule" not in spec:
  81. check.kind = "simple"
  82. check.timeout = td(seconds=spec["timeout"])
  83. if "grace" in spec:
  84. check.grace = td(seconds=spec["grace"])
  85. if "schedule" in spec:
  86. check.kind = "cron"
  87. check.schedule = spec["schedule"]
  88. if "tz" in spec:
  89. check.tz = spec["tz"]
  90. check.alert_after = check.going_down_after()
  91. check.save()
  92. # This needs to be done after saving the check, because of
  93. # the M2M relation between checks and channels:
  94. if spec.get("channels") == "*":
  95. check.assign_all_channels()
  96. elif spec.get("channels") == "":
  97. check.channel_set.clear()
  98. elif channels:
  99. check.channel_set.set(channels)
  100. return check
  101. @validate_json()
  102. @authorize_read
  103. def get_checks(request):
  104. q = Check.objects.filter(project=request.project)
  105. q = q.prefetch_related("channel_set")
  106. tags = set(request.GET.getlist("tag"))
  107. for tag in tags:
  108. # approximate filtering by tags
  109. q = q.filter(tags__contains=tag)
  110. checks = []
  111. for check in q:
  112. # precise, final filtering
  113. if not tags or check.matches_tag_set(tags):
  114. checks.append(check.to_dict(readonly=request.readonly))
  115. return JsonResponse({"checks": checks})
  116. @validate_json(schemas.check)
  117. @authorize
  118. def create_check(request):
  119. created = False
  120. check = _lookup(request.project, request.json)
  121. if check is None:
  122. if request.project.num_checks_available() <= 0:
  123. return HttpResponseForbidden()
  124. check = Check(project=request.project)
  125. created = True
  126. try:
  127. _update(check, request.json)
  128. except BadChannelException as e:
  129. return JsonResponse({"error": str(e)}, status=400)
  130. return JsonResponse(check.to_dict(), status=201 if created else 200)
  131. @csrf_exempt
  132. @cors("GET", "POST")
  133. def checks(request):
  134. if request.method == "POST":
  135. return create_check(request)
  136. return get_checks(request)
  137. @cors("GET")
  138. @validate_json()
  139. @authorize
  140. def channels(request):
  141. q = Channel.objects.filter(project=request.project)
  142. channels = [ch.to_dict() for ch in q]
  143. return JsonResponse({"channels": channels})
  144. @validate_json()
  145. @authorize_read
  146. def get_check(request, code):
  147. check = get_object_or_404(Check, code=code)
  148. if check.project_id != request.project.id:
  149. return HttpResponseForbidden()
  150. return JsonResponse(check.to_dict(readonly=request.readonly))
  151. @cors("GET")
  152. @csrf_exempt
  153. @validate_json()
  154. @authorize_read
  155. def get_check_by_unique_key(request, unique_key):
  156. checks = Check.objects.filter(project=request.project.id)
  157. for check in checks:
  158. if check.unique_key == unique_key:
  159. return JsonResponse(check.to_dict(readonly=request.readonly))
  160. return HttpResponseNotFound()
  161. @validate_json(schemas.check)
  162. @authorize
  163. def update_check(request, code):
  164. check = get_object_or_404(Check, code=code)
  165. if check.project_id != request.project.id:
  166. return HttpResponseForbidden()
  167. try:
  168. _update(check, request.json)
  169. except BadChannelException as e:
  170. return JsonResponse({"error": str(e)}, status=400)
  171. return JsonResponse(check.to_dict())
  172. @validate_json()
  173. @authorize
  174. def delete_check(request, code):
  175. check = get_object_or_404(Check, code=code)
  176. if check.project_id != request.project.id:
  177. return HttpResponseForbidden()
  178. response = check.to_dict()
  179. check.delete()
  180. return JsonResponse(response)
  181. @csrf_exempt
  182. @cors("POST", "DELETE", "GET")
  183. def single(request, code):
  184. if request.method == "POST":
  185. return update_check(request, code)
  186. if request.method == "DELETE":
  187. return delete_check(request, code)
  188. return get_check(request, code)
  189. @cors("POST")
  190. @csrf_exempt
  191. @validate_json()
  192. @authorize
  193. def pause(request, code):
  194. check = get_object_or_404(Check, code=code)
  195. if check.project_id != request.project.id:
  196. return HttpResponseForbidden()
  197. check.status = "paused"
  198. check.last_start = None
  199. check.alert_after = None
  200. check.save()
  201. return JsonResponse(check.to_dict())
  202. @cors("GET")
  203. @validate_json()
  204. @authorize
  205. def pings(request, code):
  206. check = get_object_or_404(Check, code=code)
  207. if check.project_id != request.project.id:
  208. return HttpResponseForbidden()
  209. # Look up ping log limit from account's profile.
  210. # There might be more pings in the database (depends on how pruning is handled)
  211. # but we will not return more than the limit allows.
  212. profile = Profile.objects.get(user__project=request.project)
  213. limit = profile.ping_log_limit
  214. # Query in descending order so we're sure to get the most recent
  215. # pings, regardless of the limit restriction
  216. pings = Ping.objects.filter(owner=check).order_by("-id")[:limit]
  217. # Ascending order is more convenient for calculating duration, so use reverse()
  218. prev, dicts = None, []
  219. for ping in reversed(pings):
  220. d = ping.to_dict()
  221. if ping.kind != "start" and prev and prev.kind == "start":
  222. delta = ping.created - prev.created
  223. if delta < MAX_DELTA:
  224. d["duration"] = delta.total_seconds()
  225. dicts.insert(0, d)
  226. prev = ping
  227. return JsonResponse({"pings": dicts})
  228. def flips(request, check):
  229. if check.project_id != request.project.id:
  230. return HttpResponseForbidden()
  231. form = FlipsFiltersForm(request.GET)
  232. if not form.is_valid():
  233. return HttpResponseBadRequest()
  234. flips = Flip.objects.filter(owner=check).order_by("-id")
  235. if form.cleaned_data["start"]:
  236. flips = flips.filter(created__gte=form.cleaned_data["start"])
  237. if form.cleaned_data["end"]:
  238. flips = flips.filter(created__lt=form.cleaned_data["end"])
  239. if form.cleaned_data["seconds"]:
  240. threshold = timezone.now() - td(seconds=form.cleaned_data["seconds"])
  241. flips = flips.filter(created__gte=threshold)
  242. return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
  243. @cors("GET")
  244. @csrf_exempt
  245. @validate_json()
  246. @authorize_read
  247. def flips_by_uuid(request, code):
  248. check = get_object_or_404(Check, code=code)
  249. return flips(request, check)
  250. @cors("GET")
  251. @csrf_exempt
  252. @validate_json()
  253. @authorize_read
  254. def flips_by_unique_key(request, unique_key):
  255. checks = Check.objects.filter(project=request.project.id)
  256. for check in checks:
  257. if check.unique_key == unique_key:
  258. return flips(request, check)
  259. return HttpResponseNotFound()
  260. @never_cache
  261. @cors("GET")
  262. def badge(request, badge_key, signature, tag, fmt="svg"):
  263. if not check_signature(badge_key, tag, signature):
  264. return HttpResponseNotFound()
  265. if fmt not in ("svg", "json", "shields"):
  266. return HttpResponseNotFound()
  267. q = Check.objects.filter(project__badge_key=badge_key)
  268. if tag != "*":
  269. q = q.filter(tags__contains=tag)
  270. label = tag
  271. else:
  272. label = settings.MASTER_BADGE_LABEL
  273. status, total, grace, down = "up", 0, 0, 0
  274. for check in q:
  275. if tag != "*" and tag not in check.tags_list():
  276. continue
  277. total += 1
  278. check_status = check.get_status()
  279. if check_status == "down":
  280. down += 1
  281. status = "down"
  282. if fmt == "svg":
  283. # For SVG badges, we can leave the loop as soon as we
  284. # find the first "down"
  285. break
  286. elif check_status == "grace":
  287. grace += 1
  288. if status == "up":
  289. status = "late"
  290. if fmt == "shields":
  291. color = "success"
  292. if status == "down":
  293. color = "critical"
  294. elif status == "late":
  295. color = "important"
  296. return JsonResponse({"label": label, "message": status, "color": color})
  297. if fmt == "json":
  298. return JsonResponse(
  299. {"status": status, "total": total, "grace": grace, "down": down}
  300. )
  301. svg = get_badge_svg(label, status)
  302. return HttpResponse(svg, content_type="image/svg+xml")
  303. @csrf_exempt
  304. @require_POST
  305. def bounce(request, code):
  306. notification = get_object_or_404(Notification, code=code)
  307. # If webhook is more than 10 minutes late, don't accept it:
  308. td = timezone.now() - notification.created
  309. if td.total_seconds() > 600:
  310. return HttpResponseForbidden()
  311. notification.error = request.body.decode()[:200]
  312. notification.save()
  313. notification.channel.last_error = notification.error
  314. if request.GET.get("type") in (None, "Permanent"):
  315. # For permanent bounces, mark the channel as not verified, so we
  316. # will not try to deliver to it again.
  317. notification.channel.email_verified = False
  318. notification.channel.save()
  319. return HttpResponse()
  320. @csrf_exempt
  321. @require_POST
  322. def notification_status(request, code):
  323. """ Handle notification delivery status callbacks. """
  324. notification = get_object_or_404(Notification, code=code)
  325. # If webhook is more than 1 hour late, don't accept it:
  326. td = timezone.now() - notification.created
  327. if td.total_seconds() > 3600:
  328. return HttpResponseForbidden()
  329. error, mark_not_verified = None, False
  330. # Look for "error" and "mark_not_verified" keys:
  331. if request.POST.get("error"):
  332. error = request.POST["error"][:200]
  333. mark_not_verified = request.POST.get("mark_not_verified")
  334. # Handle "MessageStatus" key from Twilio
  335. if request.POST.get("MessageStatus") in ("failed", "undelivered"):
  336. status = request.POST["MessageStatus"]
  337. error = f"Delivery failed (status={status})."
  338. # Handle "CallStatus" key from Twilio
  339. if request.POST.get("CallStatus") == "failed":
  340. error = f"Delivery failed (status=failed)."
  341. if error:
  342. notification.error = error
  343. notification.save(update_fields=["error"])
  344. channel_q = Channel.objects.filter(id=notification.channel_id)
  345. channel_q.update(last_error=error)
  346. if mark_not_verified:
  347. channel_q.update(email_verified=False)
  348. return HttpResponse()
  349. def metrics(request):
  350. if not settings.METRICS_KEY:
  351. return HttpResponseForbidden()
  352. key = request.META.get("HTTP_X_METRICS_KEY")
  353. if key != settings.METRICS_KEY:
  354. return HttpResponseForbidden()
  355. doc = {
  356. "ts": int(time.time()),
  357. "max_ping_id": Ping.objects.values_list("id", flat=True).last(),
  358. "max_notification_id": Notification.objects.values_list("id", flat=True).last(),
  359. "num_unprocessed_flips": Flip.objects.filter(processed__isnull=True).count(),
  360. }
  361. return JsonResponse(doc)
  362. def status(request):
  363. with connection.cursor() as c:
  364. c.execute("SELECT 1")
  365. c.fetchone()
  366. return HttpResponse("OK")