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.

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