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.

498 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. return check
  130. @validate_json()
  131. @authorize_read
  132. def get_checks(request):
  133. q = Check.objects.filter(project=request.project)
  134. if not request.readonly:
  135. q = q.prefetch_related("channel_set")
  136. tags = set(request.GET.getlist("tag"))
  137. for tag in tags:
  138. # approximate filtering by tags
  139. q = q.filter(tags__contains=tag)
  140. checks = []
  141. for check in q:
  142. # precise, final filtering
  143. if not tags or check.matches_tag_set(tags):
  144. checks.append(check.to_dict(readonly=request.readonly))
  145. return JsonResponse({"checks": checks})
  146. @validate_json(schemas.check)
  147. @authorize
  148. def create_check(request):
  149. created = False
  150. check = _lookup(request.project, request.json)
  151. if check is None:
  152. if request.project.num_checks_available() <= 0:
  153. return HttpResponseForbidden()
  154. check = Check(project=request.project)
  155. created = True
  156. try:
  157. _update(check, request.json)
  158. except BadChannelException as e:
  159. return JsonResponse({"error": str(e)}, status=400)
  160. return JsonResponse(check.to_dict(), status=201 if created else 200)
  161. @csrf_exempt
  162. @cors("GET", "POST")
  163. def checks(request):
  164. if request.method == "POST":
  165. return create_check(request)
  166. return get_checks(request)
  167. @cors("GET")
  168. @validate_json()
  169. @authorize
  170. def channels(request):
  171. q = Channel.objects.filter(project=request.project)
  172. channels = [ch.to_dict() for ch in q]
  173. return JsonResponse({"channels": channels})
  174. @validate_json()
  175. @authorize_read
  176. def get_check(request, code):
  177. check = get_object_or_404(Check, code=code)
  178. if check.project_id != request.project.id:
  179. return HttpResponseForbidden()
  180. return JsonResponse(check.to_dict(readonly=request.readonly))
  181. @cors("GET")
  182. @csrf_exempt
  183. @validate_json()
  184. @authorize_read
  185. def get_check_by_unique_key(request, unique_key):
  186. checks = Check.objects.filter(project=request.project.id)
  187. for check in checks:
  188. if check.unique_key == unique_key:
  189. return JsonResponse(check.to_dict(readonly=request.readonly))
  190. return HttpResponseNotFound()
  191. @validate_json(schemas.check)
  192. @authorize
  193. def update_check(request, code):
  194. check = get_object_or_404(Check, code=code)
  195. if check.project_id != request.project.id:
  196. return HttpResponseForbidden()
  197. try:
  198. _update(check, request.json)
  199. except BadChannelException as e:
  200. return JsonResponse({"error": str(e)}, status=400)
  201. return JsonResponse(check.to_dict())
  202. @validate_json()
  203. @authorize
  204. def delete_check(request, code):
  205. check = get_object_or_404(Check, code=code)
  206. if check.project_id != request.project.id:
  207. return HttpResponseForbidden()
  208. response = check.to_dict()
  209. check.delete()
  210. return JsonResponse(response)
  211. @csrf_exempt
  212. @cors("POST", "DELETE", "GET")
  213. def single(request, code):
  214. if request.method == "POST":
  215. return update_check(request, code)
  216. if request.method == "DELETE":
  217. return delete_check(request, code)
  218. return get_check(request, code)
  219. @cors("POST")
  220. @csrf_exempt
  221. @validate_json()
  222. @authorize
  223. def pause(request, code):
  224. check = get_object_or_404(Check, code=code)
  225. if check.project_id != request.project.id:
  226. return HttpResponseForbidden()
  227. check.status = "paused"
  228. check.last_start = None
  229. check.alert_after = None
  230. check.save()
  231. return JsonResponse(check.to_dict())
  232. @cors("GET")
  233. @validate_json()
  234. @authorize
  235. def pings(request, code):
  236. check = get_object_or_404(Check, code=code)
  237. if check.project_id != request.project.id:
  238. return HttpResponseForbidden()
  239. # Look up ping log limit from account's profile.
  240. # There might be more pings in the database (depends on how pruning is handled)
  241. # but we will not return more than the limit allows.
  242. profile = Profile.objects.get(user__project=request.project)
  243. limit = profile.ping_log_limit
  244. # Query in descending order so we're sure to get the most recent
  245. # pings, regardless of the limit restriction
  246. pings = Ping.objects.filter(owner=check).order_by("-id")[:limit]
  247. # Ascending order is more convenient for calculating duration, so use reverse()
  248. prev, dicts = None, []
  249. for ping in reversed(pings):
  250. d = ping.to_dict()
  251. if ping.kind != "start" and prev and prev.kind == "start":
  252. delta = ping.created - prev.created
  253. if delta < MAX_DELTA:
  254. d["duration"] = delta.total_seconds()
  255. dicts.insert(0, d)
  256. prev = ping
  257. return JsonResponse({"pings": dicts})
  258. def flips(request, check):
  259. if check.project_id != request.project.id:
  260. return HttpResponseForbidden()
  261. form = FlipsFiltersForm(request.GET)
  262. if not form.is_valid():
  263. return HttpResponseBadRequest()
  264. flips = Flip.objects.filter(owner=check).order_by("-id")
  265. if form.cleaned_data["start"]:
  266. flips = flips.filter(created__gte=form.cleaned_data["start"])
  267. if form.cleaned_data["end"]:
  268. flips = flips.filter(created__lt=form.cleaned_data["end"])
  269. if form.cleaned_data["seconds"]:
  270. threshold = timezone.now() - td(seconds=form.cleaned_data["seconds"])
  271. flips = flips.filter(created__gte=threshold)
  272. return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
  273. @cors("GET")
  274. @csrf_exempt
  275. @validate_json()
  276. @authorize_read
  277. def flips_by_uuid(request, code):
  278. check = get_object_or_404(Check, code=code)
  279. return flips(request, check)
  280. @cors("GET")
  281. @csrf_exempt
  282. @validate_json()
  283. @authorize_read
  284. def flips_by_unique_key(request, unique_key):
  285. checks = Check.objects.filter(project=request.project.id)
  286. for check in checks:
  287. if check.unique_key == unique_key:
  288. return flips(request, check)
  289. return HttpResponseNotFound()
  290. @never_cache
  291. @cors("GET")
  292. def badge(request, badge_key, signature, tag, fmt):
  293. if fmt not in ("svg", "json", "shields"):
  294. return HttpResponseNotFound()
  295. with_late = True
  296. if len(signature) == 10 and signature.endswith("-2"):
  297. with_late = False
  298. if not check_signature(badge_key, tag, signature):
  299. return HttpResponseNotFound()
  300. q = Check.objects.filter(project__badge_key=badge_key)
  301. if tag != "*":
  302. q = q.filter(tags__contains=tag)
  303. label = tag
  304. else:
  305. label = settings.MASTER_BADGE_LABEL
  306. status, total, grace, down = "up", 0, 0, 0
  307. for check in q:
  308. if tag != "*" and tag not in check.tags_list():
  309. continue
  310. total += 1
  311. check_status = check.get_status()
  312. if check_status == "down":
  313. down += 1
  314. status = "down"
  315. if fmt == "svg":
  316. # For SVG badges, we can leave the loop as soon as we
  317. # find the first "down"
  318. break
  319. elif check_status == "grace":
  320. grace += 1
  321. if status == "up" and with_late:
  322. status = "late"
  323. if fmt == "shields":
  324. color = "success"
  325. if status == "down":
  326. color = "critical"
  327. elif status == "late":
  328. color = "important"
  329. return JsonResponse({"label": label, "message": status, "color": color})
  330. if fmt == "json":
  331. return JsonResponse(
  332. {"status": status, "total": total, "grace": grace, "down": down}
  333. )
  334. svg = get_badge_svg(label, status)
  335. return HttpResponse(svg, content_type="image/svg+xml")
  336. @csrf_exempt
  337. @require_POST
  338. def notification_status(request, code):
  339. """ Handle notification delivery status callbacks. """
  340. notification = get_object_or_404(Notification, code=code)
  341. td = timezone.now() - notification.created
  342. if td.total_seconds() > 3600:
  343. # If the webhook is called more than 1 hour after the notification, ignore it.
  344. # Return HTTP 200 so the other party doesn't retry over and over again:
  345. return HttpResponse()
  346. error, mark_not_verified = None, False
  347. # Look for "error" and "mark_not_verified" keys:
  348. if request.POST.get("error"):
  349. error = request.POST["error"][:200]
  350. mark_not_verified = request.POST.get("mark_not_verified")
  351. # Handle "MessageStatus" key from Twilio
  352. if request.POST.get("MessageStatus") in ("failed", "undelivered"):
  353. status = request.POST["MessageStatus"]
  354. error = f"Delivery failed (status={status})."
  355. # Handle "CallStatus" key from Twilio
  356. if request.POST.get("CallStatus") == "failed":
  357. error = f"Delivery failed (status=failed)."
  358. if error:
  359. notification.error = error
  360. notification.save(update_fields=["error"])
  361. channel_q = Channel.objects.filter(id=notification.channel_id)
  362. channel_q.update(last_error=error)
  363. if mark_not_verified:
  364. channel_q.update(email_verified=False)
  365. return HttpResponse()
  366. def metrics(request):
  367. if not settings.METRICS_KEY:
  368. return HttpResponseForbidden()
  369. key = request.META.get("HTTP_X_METRICS_KEY")
  370. if key != settings.METRICS_KEY:
  371. return HttpResponseForbidden()
  372. doc = {
  373. "ts": int(time.time()),
  374. "max_ping_id": Ping.objects.values_list("id", flat=True).last(),
  375. "max_notification_id": Notification.objects.values_list("id", flat=True).last(),
  376. "num_unprocessed_flips": Flip.objects.filter(processed__isnull=True).count(),
  377. }
  378. return JsonResponse(doc)
  379. def status(request):
  380. with connection.cursor() as c:
  381. c.execute("SELECT 1")
  382. c.fetchone()
  383. return HttpResponse("OK")