from datetime import timedelta as td
|
|
import time
|
|
|
|
from django.conf import settings
|
|
from django.db import connection
|
|
from django.http import (
|
|
HttpResponse,
|
|
HttpResponseForbidden,
|
|
HttpResponseNotFound,
|
|
HttpResponseBadRequest,
|
|
JsonResponse,
|
|
)
|
|
from django.shortcuts import get_object_or_404
|
|
from django.utils import timezone
|
|
from django.views.decorators.cache import never_cache
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.http import require_POST
|
|
|
|
from hc.accounts.models import Profile
|
|
from hc.api import schemas
|
|
from hc.api.decorators import authorize, authorize_read, cors, validate_json
|
|
from hc.api.forms import FlipsFiltersForm
|
|
from hc.api.models import MAX_DELTA, Flip, Channel, Check, Notification, Ping
|
|
from hc.lib.badges import check_signature, get_badge_svg, get_badge_url
|
|
|
|
|
|
class BadChannelException(Exception):
|
|
def __init__(self, message):
|
|
self.message = message
|
|
|
|
|
|
@csrf_exempt
|
|
@never_cache
|
|
def ping(request, code, check=None, action="success", exitstatus=None):
|
|
if check is None:
|
|
try:
|
|
check = Check.objects.get(code=code)
|
|
except Check.DoesNotExist:
|
|
return HttpResponseNotFound("not found")
|
|
|
|
if exitstatus is not None and exitstatus > 255:
|
|
return HttpResponseBadRequest("invalid url format")
|
|
|
|
headers = request.META
|
|
remote_addr = headers.get("HTTP_X_FORWARDED_FOR", headers["REMOTE_ADDR"])
|
|
remote_addr = remote_addr.split(",")[0]
|
|
scheme = headers.get("HTTP_X_FORWARDED_PROTO", "http")
|
|
method = headers["REQUEST_METHOD"]
|
|
ua = headers.get("HTTP_USER_AGENT", "")
|
|
body = request.body.decode()
|
|
|
|
if exitstatus is not None and exitstatus > 0:
|
|
action = "fail"
|
|
|
|
if check.methods == "POST" and method != "POST":
|
|
action = "ign"
|
|
|
|
check.ping(remote_addr, scheme, method, ua, body, action, exitstatus)
|
|
|
|
response = HttpResponse("OK")
|
|
response["Access-Control-Allow-Origin"] = "*"
|
|
return response
|
|
|
|
|
|
@csrf_exempt
|
|
def ping_by_slug(request, ping_key, slug, action="success", exitstatus=None):
|
|
try:
|
|
check = Check.objects.get(slug=slug, project__ping_key=ping_key)
|
|
except Check.DoesNotExist:
|
|
return HttpResponseNotFound("not found")
|
|
except Check.MultipleObjectsReturned:
|
|
return HttpResponse("ambiguous slug", status=409)
|
|
|
|
return ping(request, check.code, check, action, exitstatus)
|
|
|
|
|
|
def _lookup(project, spec):
|
|
unique_fields = spec.get("unique", [])
|
|
if unique_fields:
|
|
existing_checks = Check.objects.filter(project=project)
|
|
if "name" in unique_fields:
|
|
existing_checks = existing_checks.filter(name=spec.get("name"))
|
|
if "tags" in unique_fields:
|
|
existing_checks = existing_checks.filter(tags=spec.get("tags"))
|
|
if "timeout" in unique_fields:
|
|
timeout = td(seconds=spec["timeout"])
|
|
existing_checks = existing_checks.filter(timeout=timeout)
|
|
if "grace" in unique_fields:
|
|
grace = td(seconds=spec["grace"])
|
|
existing_checks = existing_checks.filter(grace=grace)
|
|
|
|
return existing_checks.first()
|
|
|
|
|
|
def _update(check, spec):
|
|
# First, validate the supplied channel codes/names
|
|
if "channels" not in spec:
|
|
# If the channels key is not present, don't update check's channels
|
|
new_channels = None
|
|
elif spec["channels"] == "*":
|
|
# "*" means "all project's channels"
|
|
new_channels = Channel.objects.filter(project=check.project)
|
|
elif spec.get("channels") == "":
|
|
# "" means "empty list"
|
|
new_channels = []
|
|
else:
|
|
# expect a comma-separated list of channel codes or names
|
|
new_channels = set()
|
|
available = list(Channel.objects.filter(project=check.project))
|
|
|
|
for s in spec["channels"].split(","):
|
|
if s == "":
|
|
raise BadChannelException("empty channel identifier")
|
|
|
|
matches = [c for c in available if str(c.code) == s or c.name == s]
|
|
if len(matches) == 0:
|
|
raise BadChannelException("invalid channel identifier: %s" % s)
|
|
elif len(matches) > 1:
|
|
raise BadChannelException("non-unique channel identifier: %s" % s)
|
|
|
|
new_channels.add(matches[0])
|
|
|
|
need_save = False
|
|
if check.pk is None:
|
|
# Empty pk means we're inserting a new check,
|
|
# and so do need to save() it:
|
|
need_save = True
|
|
|
|
if "name" in spec and check.name != spec["name"]:
|
|
check.set_name_slug(spec["name"])
|
|
need_save = True
|
|
|
|
if "tags" in spec and check.tags != spec["tags"]:
|
|
check.tags = spec["tags"]
|
|
need_save = True
|
|
|
|
if "desc" in spec and check.desc != spec["desc"]:
|
|
check.desc = spec["desc"]
|
|
need_save = True
|
|
|
|
if "manual_resume" in spec and check.manual_resume != spec["manual_resume"]:
|
|
check.manual_resume = spec["manual_resume"]
|
|
need_save = True
|
|
|
|
if "methods" in spec and check.methods != spec["methods"]:
|
|
check.methods = spec["methods"]
|
|
need_save = True
|
|
|
|
if "timeout" in spec and "schedule" not in spec:
|
|
new_timeout = td(seconds=spec["timeout"])
|
|
if check.kind != "simple" or check.timeout != new_timeout:
|
|
check.kind = "simple"
|
|
check.timeout = new_timeout
|
|
need_save = True
|
|
|
|
if "grace" in spec:
|
|
new_grace = td(seconds=spec["grace"])
|
|
if check.grace != new_grace:
|
|
check.grace = new_grace
|
|
need_save = True
|
|
|
|
if "schedule" in spec:
|
|
if check.kind != "cron" or check.schedule != spec["schedule"]:
|
|
check.kind = "cron"
|
|
check.schedule = spec["schedule"]
|
|
need_save = True
|
|
|
|
if "tz" in spec and check.tz != spec["tz"]:
|
|
check.tz = spec["tz"]
|
|
need_save = True
|
|
|
|
if need_save:
|
|
check.alert_after = check.going_down_after()
|
|
check.save()
|
|
|
|
# This needs to be done after saving the check, because of
|
|
# the M2M relation between checks and channels:
|
|
if new_channels is not None:
|
|
check.channel_set.set(new_channels)
|
|
|
|
|
|
@authorize_read
|
|
def get_checks(request):
|
|
q = Check.objects.filter(project=request.project)
|
|
if not request.readonly:
|
|
q = q.prefetch_related("channel_set")
|
|
|
|
tags = set(request.GET.getlist("tag"))
|
|
for tag in tags:
|
|
# approximate filtering by tags
|
|
q = q.filter(tags__contains=tag)
|
|
|
|
checks = []
|
|
for check in q:
|
|
# precise, final filtering
|
|
if not tags or check.matches_tag_set(tags):
|
|
checks.append(check.to_dict(readonly=request.readonly))
|
|
|
|
return JsonResponse({"checks": checks})
|
|
|
|
|
|
@validate_json(schemas.check)
|
|
@authorize
|
|
def create_check(request):
|
|
created = False
|
|
check = _lookup(request.project, request.json)
|
|
if check is None:
|
|
if request.project.num_checks_available() <= 0:
|
|
return HttpResponseForbidden()
|
|
|
|
check = Check(project=request.project)
|
|
created = True
|
|
|
|
try:
|
|
_update(check, request.json)
|
|
except BadChannelException as e:
|
|
return JsonResponse({"error": e.message}, status=400)
|
|
|
|
return JsonResponse(check.to_dict(), status=201 if created else 200)
|
|
|
|
|
|
@csrf_exempt
|
|
@cors("GET", "POST")
|
|
def checks(request):
|
|
if request.method == "POST":
|
|
return create_check(request)
|
|
|
|
return get_checks(request)
|
|
|
|
|
|
@cors("GET")
|
|
@csrf_exempt
|
|
@authorize
|
|
def channels(request):
|
|
q = Channel.objects.filter(project=request.project)
|
|
channels = [ch.to_dict() for ch in q]
|
|
return JsonResponse({"channels": channels})
|
|
|
|
|
|
@authorize_read
|
|
def get_check(request, code):
|
|
check = get_object_or_404(Check, code=code)
|
|
if check.project_id != request.project.id:
|
|
return HttpResponseForbidden()
|
|
return JsonResponse(check.to_dict(readonly=request.readonly))
|
|
|
|
|
|
@cors("GET")
|
|
@csrf_exempt
|
|
@authorize_read
|
|
def get_check_by_unique_key(request, unique_key):
|
|
checks = Check.objects.filter(project=request.project.id)
|
|
for check in checks:
|
|
if check.unique_key == unique_key:
|
|
return JsonResponse(check.to_dict(readonly=request.readonly))
|
|
return HttpResponseNotFound()
|
|
|
|
|
|
@validate_json(schemas.check)
|
|
@authorize
|
|
def update_check(request, code):
|
|
check = get_object_or_404(Check, code=code)
|
|
if check.project_id != request.project.id:
|
|
return HttpResponseForbidden()
|
|
|
|
try:
|
|
_update(check, request.json)
|
|
except BadChannelException as e:
|
|
return JsonResponse({"error": e.message}, status=400)
|
|
|
|
return JsonResponse(check.to_dict())
|
|
|
|
|
|
@authorize
|
|
def delete_check(request, code):
|
|
check = get_object_or_404(Check, code=code)
|
|
if check.project_id != request.project.id:
|
|
return HttpResponseForbidden()
|
|
|
|
response = check.to_dict()
|
|
check.delete()
|
|
return JsonResponse(response)
|
|
|
|
|
|
@csrf_exempt
|
|
@cors("POST", "DELETE", "GET")
|
|
def single(request, code):
|
|
if request.method == "POST":
|
|
return update_check(request, code)
|
|
|
|
if request.method == "DELETE":
|
|
return delete_check(request, code)
|
|
|
|
return get_check(request, code)
|
|
|
|
|
|
@cors("POST")
|
|
@csrf_exempt
|
|
@validate_json()
|
|
@authorize
|
|
def pause(request, code):
|
|
check = get_object_or_404(Check, code=code)
|
|
if check.project_id != request.project.id:
|
|
return HttpResponseForbidden()
|
|
|
|
check.status = "paused"
|
|
check.last_start = None
|
|
check.alert_after = None
|
|
check.save()
|
|
|
|
# After pausing a check we must check if all checks are up,
|
|
# and Profile.next_nag_date needs to be cleared out:
|
|
check.project.update_next_nag_dates()
|
|
|
|
return JsonResponse(check.to_dict())
|
|
|
|
|
|
@cors("GET")
|
|
@csrf_exempt
|
|
@validate_json()
|
|
@authorize
|
|
def pings(request, code):
|
|
check = get_object_or_404(Check, code=code)
|
|
if check.project_id != request.project.id:
|
|
return HttpResponseForbidden()
|
|
|
|
# Look up ping log limit from account's profile.
|
|
# There might be more pings in the database (depends on how pruning is handled)
|
|
# but we will not return more than the limit allows.
|
|
profile = Profile.objects.get(user__project=request.project)
|
|
limit = profile.ping_log_limit
|
|
|
|
# Query in descending order so we're sure to get the most recent
|
|
# pings, regardless of the limit restriction
|
|
pings = Ping.objects.filter(owner=check).order_by("-id")[:limit]
|
|
|
|
# Ascending order is more convenient for calculating duration, so use reverse()
|
|
prev, dicts = None, []
|
|
for ping in reversed(pings):
|
|
d = ping.to_dict()
|
|
if ping.kind != "start" and prev and prev.kind == "start":
|
|
delta = ping.created - prev.created
|
|
if delta < MAX_DELTA:
|
|
d["duration"] = delta.total_seconds()
|
|
|
|
dicts.insert(0, d)
|
|
prev = ping
|
|
|
|
return JsonResponse({"pings": dicts})
|
|
|
|
|
|
def flips(request, check):
|
|
if check.project_id != request.project.id:
|
|
return HttpResponseForbidden()
|
|
|
|
form = FlipsFiltersForm(request.GET)
|
|
if not form.is_valid():
|
|
return HttpResponseBadRequest()
|
|
|
|
flips = Flip.objects.filter(owner=check).order_by("-id")
|
|
|
|
if form.cleaned_data["start"]:
|
|
flips = flips.filter(created__gte=form.cleaned_data["start"])
|
|
|
|
if form.cleaned_data["end"]:
|
|
flips = flips.filter(created__lt=form.cleaned_data["end"])
|
|
|
|
if form.cleaned_data["seconds"]:
|
|
threshold = timezone.now() - td(seconds=form.cleaned_data["seconds"])
|
|
flips = flips.filter(created__gte=threshold)
|
|
|
|
return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
|
|
|
|
|
|
@cors("GET")
|
|
@csrf_exempt
|
|
@authorize_read
|
|
def flips_by_uuid(request, code):
|
|
check = get_object_or_404(Check, code=code)
|
|
return flips(request, check)
|
|
|
|
|
|
@cors("GET")
|
|
@csrf_exempt
|
|
@authorize_read
|
|
def flips_by_unique_key(request, unique_key):
|
|
checks = Check.objects.filter(project=request.project.id)
|
|
for check in checks:
|
|
if check.unique_key == unique_key:
|
|
return flips(request, check)
|
|
return HttpResponseNotFound()
|
|
|
|
|
|
@cors("GET")
|
|
@csrf_exempt
|
|
@authorize_read
|
|
def badges(request):
|
|
tags = set(["*"])
|
|
for check in Check.objects.filter(project=request.project):
|
|
tags.update(check.tags_list())
|
|
|
|
key = request.project.badge_key
|
|
badges = {}
|
|
for tag in tags:
|
|
badges[tag] = {
|
|
"svg": get_badge_url(key, tag),
|
|
"svg3": get_badge_url(key, tag, with_late=True),
|
|
"json": get_badge_url(key, tag, fmt="json"),
|
|
"json3": get_badge_url(key, tag, fmt="json", with_late=True),
|
|
"shields": get_badge_url(key, tag, fmt="shields"),
|
|
"shields3": get_badge_url(key, tag, fmt="shields", with_late=True),
|
|
}
|
|
|
|
return JsonResponse({"badges": badges})
|
|
|
|
|
|
@never_cache
|
|
@cors("GET")
|
|
def badge(request, badge_key, signature, tag, fmt):
|
|
if fmt not in ("svg", "json", "shields"):
|
|
return HttpResponseNotFound()
|
|
|
|
with_late = True
|
|
if len(signature) == 10 and signature.endswith("-2"):
|
|
with_late = False
|
|
|
|
if not check_signature(badge_key, tag, signature):
|
|
return HttpResponseNotFound()
|
|
|
|
q = Check.objects.filter(project__badge_key=badge_key)
|
|
if tag != "*":
|
|
q = q.filter(tags__contains=tag)
|
|
label = tag
|
|
else:
|
|
label = settings.MASTER_BADGE_LABEL
|
|
|
|
status, total, grace, down = "up", 0, 0, 0
|
|
for check in q:
|
|
if tag != "*" and tag not in check.tags_list():
|
|
continue
|
|
|
|
total += 1
|
|
check_status = check.get_status()
|
|
|
|
if check_status == "down":
|
|
down += 1
|
|
status = "down"
|
|
if fmt == "svg":
|
|
# For SVG badges, we can leave the loop as soon as we
|
|
# find the first "down"
|
|
break
|
|
elif check_status == "grace":
|
|
grace += 1
|
|
if status == "up" and with_late:
|
|
status = "late"
|
|
|
|
if fmt == "shields":
|
|
color = "success"
|
|
if status == "down":
|
|
color = "critical"
|
|
elif status == "late":
|
|
color = "important"
|
|
|
|
return JsonResponse(
|
|
{"schemaVersion": 1, "label": label, "message": status, "color": color}
|
|
)
|
|
|
|
if fmt == "json":
|
|
return JsonResponse(
|
|
{"status": status, "total": total, "grace": grace, "down": down}
|
|
)
|
|
|
|
svg = get_badge_svg(label, status)
|
|
return HttpResponse(svg, content_type="image/svg+xml")
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def notification_status(request, code):
|
|
""" Handle notification delivery status callbacks. """
|
|
|
|
try:
|
|
cutoff = timezone.now() - td(hours=1)
|
|
notification = Notification.objects.get(code=code, created__gt=cutoff)
|
|
except Notification.DoesNotExist:
|
|
# If the notification does not exist, or is more than a hour old,
|
|
# return HTTP 200 so the other party doesn't retry over and over again:
|
|
return HttpResponse()
|
|
|
|
error, mark_not_verified = None, False
|
|
|
|
# Look for "error" and "mark_not_verified" keys:
|
|
if request.POST.get("error"):
|
|
error = request.POST["error"][:200]
|
|
mark_not_verified = request.POST.get("mark_not_verified")
|
|
|
|
# Handle "MessageStatus" key from Twilio
|
|
if request.POST.get("MessageStatus") in ("failed", "undelivered"):
|
|
status = request.POST["MessageStatus"]
|
|
error = f"Delivery failed (status={status})."
|
|
|
|
# Handle "CallStatus" key from Twilio
|
|
if request.POST.get("CallStatus") == "failed":
|
|
error = f"Delivery failed (status=failed)."
|
|
|
|
if error:
|
|
notification.error = error
|
|
notification.save(update_fields=["error"])
|
|
|
|
channel_q = Channel.objects.filter(id=notification.channel_id)
|
|
channel_q.update(last_error=error)
|
|
if mark_not_verified:
|
|
channel_q.update(email_verified=False)
|
|
|
|
return HttpResponse()
|
|
|
|
|
|
def metrics(request):
|
|
if not settings.METRICS_KEY:
|
|
return HttpResponseForbidden()
|
|
|
|
key = request.META.get("HTTP_X_METRICS_KEY")
|
|
if key != settings.METRICS_KEY:
|
|
return HttpResponseForbidden()
|
|
|
|
doc = {
|
|
"ts": int(time.time()),
|
|
"max_ping_id": Ping.objects.values_list("id", flat=True).last(),
|
|
"max_notification_id": Notification.objects.values_list("id", flat=True).last(),
|
|
"num_unprocessed_flips": Flip.objects.filter(processed__isnull=True).count(),
|
|
}
|
|
|
|
return JsonResponse(doc)
|
|
|
|
|
|
def status(request):
|
|
with connection.cursor() as c:
|
|
c.execute("SELECT 1")
|
|
c.fetchone()
|
|
|
|
return HttpResponse("OK")
|