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.

444 lines
13 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. from datetime import datetime
  3. import time
  4. import uuid
  5. from django.conf import settings
  6. from django.db import connection
  7. from django.http import (
  8. HttpResponse,
  9. HttpResponseForbidden,
  10. HttpResponseNotFound,
  11. HttpResponseBadRequest,
  12. JsonResponse,
  13. )
  14. from django.shortcuts import get_object_or_404
  15. from django.utils import timezone
  16. from django.views.decorators.cache import never_cache
  17. from django.views.decorators.csrf import csrf_exempt
  18. from django.views.decorators.http import require_POST
  19. from hc.accounts.models import Profile
  20. from hc.api import schemas
  21. from hc.api.decorators import authorize, authorize_read, cors, validate_json
  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. if all(x not in request.GET for x in ('start','end','seconds')):
  232. flips = Flip.objects.filter(
  233. owner=check, new_status__in=("down","up"),
  234. ).order_by("created")
  235. else:
  236. if "seconds" in request.GET and ("start" in request.GET or "end" in request.GET):
  237. return HttpResponseBadRequest()
  238. flips = Flip.objects.filter(
  239. owner=check, new_status__in=("down","up"))
  240. if 'start' in request.GET:
  241. flips = flips.filter(created_gt=datetime.fromtimestamp(int(request.GET['start'])))
  242. if 'end' in request.GET:
  243. flips = flips.filter(created__lt=datetime.fromtimestamp(int(request.GET['end'])))
  244. if 'seconds' in request.GET:
  245. flips = flips.filter(created_gt=datetime.now()-td(seconds=int(request.GET['seconds'])))
  246. flips = flips.order_by("created")
  247. return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
  248. @cors("GET")
  249. @csrf_exempt
  250. @validate_json()
  251. @authorize_read
  252. def flips_by_uuid(request,code):
  253. check = get_object_or_404(Check, code=code)
  254. return flips(request,check)
  255. @cors("GET")
  256. @csrf_exempt
  257. @validate_json()
  258. @authorize_read
  259. def flips_by_unique_key(request, unique_key):
  260. checks = Check.objects.filter(project=request.project.id)
  261. for check in checks:
  262. if check.unique_key == unique_key:
  263. return flips(request,check)
  264. return HttpResponseNotFound()
  265. @never_cache
  266. @cors("GET")
  267. def badge(request, badge_key, signature, tag, fmt="svg"):
  268. if not check_signature(badge_key, tag, signature):
  269. return HttpResponseNotFound()
  270. if fmt not in ("svg", "json", "shields"):
  271. return HttpResponseNotFound()
  272. q = Check.objects.filter(project__badge_key=badge_key)
  273. if tag != "*":
  274. q = q.filter(tags__contains=tag)
  275. label = tag
  276. else:
  277. label = settings.MASTER_BADGE_LABEL
  278. status, total, grace, down = "up", 0, 0, 0
  279. for check in q:
  280. if tag != "*" and tag not in check.tags_list():
  281. continue
  282. total += 1
  283. check_status = check.get_status(with_started=False)
  284. if check_status == "down":
  285. down += 1
  286. status = "down"
  287. if fmt == "svg":
  288. # For SVG badges, we can leave the loop as soon as we
  289. # find the first "down"
  290. break
  291. elif check_status == "grace":
  292. grace += 1
  293. if status == "up":
  294. status = "late"
  295. if fmt == "shields":
  296. color = "success"
  297. if status == "down":
  298. color = "critical"
  299. elif status == "late":
  300. color = "important"
  301. return JsonResponse({"label": label, "message": status, "color": color})
  302. if fmt == "json":
  303. return JsonResponse(
  304. {"status": status, "total": total, "grace": grace, "down": down}
  305. )
  306. svg = get_badge_svg(label, status)
  307. return HttpResponse(svg, content_type="image/svg+xml")
  308. @csrf_exempt
  309. @require_POST
  310. def bounce(request, code):
  311. notification = get_object_or_404(Notification, code=code)
  312. # If webhook is more than 10 minutes late, don't accept it:
  313. td = timezone.now() - notification.created
  314. if td.total_seconds() > 600:
  315. return HttpResponseForbidden()
  316. notification.error = request.body.decode()[:200]
  317. notification.save()
  318. notification.channel.last_error = notification.error
  319. if request.GET.get("type") in (None, "Permanent"):
  320. # For permanent bounces, mark the channel as not verified, so we
  321. # will not try to deliver to it again.
  322. notification.channel.email_verified = False
  323. notification.channel.save()
  324. return HttpResponse()
  325. def metrics(request):
  326. if not settings.METRICS_KEY:
  327. return HttpResponseForbidden()
  328. key = request.META.get("HTTP_X_METRICS_KEY")
  329. if key != settings.METRICS_KEY:
  330. return HttpResponseForbidden()
  331. doc = {
  332. "ts": int(time.time()),
  333. "max_ping_id": Ping.objects.values_list("id", flat=True).last(),
  334. "max_notification_id": Notification.objects.values_list("id", flat=True).last(),
  335. "num_unprocessed_flips": Flip.objects.filter(processed__isnull=True).count(),
  336. }
  337. return JsonResponse(doc)
  338. def status(request):
  339. with connection.cursor() as c:
  340. c.execute("SELECT 1")
  341. c.fetchone()
  342. return HttpResponse("OK")