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.

539 lines
16 KiB

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