datatracker/ietf/api/views.py
Robert Sparks e5c4a9f298
feat: additional filesystem monitoring (#8405)
* feat: additional filesystem monitoring

* chore: rename setting for tmp directory

* fix: restructure path to new endpoint

---------

Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org>
2025-01-09 13:07:51 -06:00

733 lines
30 KiB
Python

# Copyright The IETF Trust 2017-2020, All Rights Reserved
# -*- coding: utf-8 -*-
import base64
import binascii
import datetime
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
import jsonschema
import pytz
import re
from contextlib import suppress
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponse, Http404, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.gzip import gzip_page
from django.views.generic.detail import DetailView
from email.message import EmailMessage
from importlib.metadata import version as metadata_version
from jwcrypto.jwk import JWK
from tastypie.exceptions import BadRequest
from tastypie.serializers import Serializer
from tastypie.utils import is_valid_jsonp_callback_value
from tastypie.utils.mime import determine_format, build_content_type
from textwrap import dedent
from traceback import format_exception, extract_tb
from typing import Iterable, Optional, Literal
import ietf
from ietf.api import _api_list
from ietf.api.ietf_utils import is_valid_token, requires_api_token
from ietf.api.serializer import JsonExportMixin
from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents
from ietf.group.utils import GroupAliasGenerator, role_holder_emails
from ietf.ietfauth.utils import role_required
from ietf.ietfauth.views import send_account_creation_email
from ietf.ipr.utils import ingest_response_email as ipr_ingest_response_email
from ietf.meeting.models import Meeting
from ietf.nomcom.models import Volunteer, NomCom
from ietf.nomcom.utils import ingest_feedback_email as nomcom_ingest_feedback_email
from ietf.person.models import Person, Email
from ietf.stats.models import MeetingRegistration
from ietf.sync.iana import ingest_review_email as iana_ingest_review_email
from ietf.utils import log
from ietf.utils.decorators import require_api_key
from ietf.utils.mail import send_smtp
from ietf.utils.models import DumpInfo
def top_level(request):
available_resources = {}
apitop = reverse('ietf.api.views.top_level')
for name in sorted([ name for name, api in _api_list if len(api._registry) > 0 ]):
available_resources[name] = {
'list_endpoint': '%s/%s/' % (apitop, name),
}
serializer = Serializer()
desired_format = determine_format(request, serializer)
options = {}
if 'text/javascript' in desired_format:
callback = request.GET.get('callback', 'callback')
if not is_valid_jsonp_callback_value(callback):
raise BadRequest('JSONP callback name is invalid.')
options['callback'] = callback
serialized = serializer.serialize(available_resources, desired_format, options)
return HttpResponse(content=serialized, content_type=build_content_type(desired_format))
def api_help(request):
key = JWK()
# import just public part here, for display in info page
key.import_from_pem(settings.API_PUBLIC_KEY_PEM)
return render(request, "api/index.html", {'key': key, 'settings':settings, })
@method_decorator((login_required, gzip_page), name='dispatch')
class PersonalInformationExportView(DetailView, JsonExportMixin):
model = Person
def get(self, request):
person = get_object_or_404(self.model, user=request.user)
expand = ['searchrule', 'documentauthor', 'ad_document_set', 'ad_dochistory_set', 'docevent',
'ballotpositiondocevent', 'deletedevent', 'email_set', 'groupevent', 'role', 'rolehistory', 'iprdisclosurebase',
'iprevent', 'liaisonstatementevent', 'allowlisted', 'schedule', 'constraint', 'schedulingevent', 'message',
'sendqueue', 'nominee', 'topicfeedbacklastseen', 'alias', 'email', 'apikeys', 'personevent',
'reviewersettings', 'reviewsecretarysettings', 'unavailableperiod', 'reviewwish',
'nextreviewerinteam', 'reviewrequest', 'meetingregistration', 'submissionevent', 'preapproval',
'user', 'communitylist', 'personextresource_set', ]
return self.json_view(request, filter={'id':person.id}, expand=expand)
@method_decorator((csrf_exempt, require_api_key, role_required('Robot')), name='dispatch')
class ApiV2PersonExportView(DetailView, JsonExportMixin):
model = Person
def err(self, code, text):
return HttpResponse(text, status=code, content_type='text/plain')
def post(self, request):
querydict = request.POST.copy()
querydict.pop('apikey', None)
expand = querydict.pop('_expand', [])
if not querydict:
return self.err(400, "No filters provided")
return self.json_view(request, filter=querydict.dict(), expand=expand)
# @require_api_key
# @csrf_exempt
# def person_access_token(request):
# person = get_object_or_404(Person, user=request.user)
#
# if request.method == 'POST':
# client_id = request.POST.get('client_id', None)
# client_secret = request.POST.get('client_secret', None)
# client = get_object_or_404(ClientRecord, client_id=client_id, client_secret=client_secret)
#
# return HttpResponse(json.dumps({
# 'name' : person.plain_name(),
# 'email': person.email().address,
# 'roles': {
# 'chair': list(person.role_set.filter(name='chair', group__state__in=['active', 'bof', 'proposed']).values_list('group__acronym', flat=True)),
# 'secr': list(person.role_set.filter(name='secr', group__state__in=['active', 'bof', 'proposed']).values_list('group__acronym', flat=True)),
# }
# }), content_type='application/json')
# else:
# return HttpResponse(status=405)
@require_api_key
@role_required('Robot')
@csrf_exempt
def api_new_meeting_registration(request):
'''REST API to notify the datatracker about a new meeting registration'''
def err(code, text):
return HttpResponse(text, status=code, content_type='text/plain')
required_fields = [ 'meeting', 'first_name', 'last_name', 'affiliation', 'country_code',
'email', 'reg_type', 'ticket_type', 'checkedin', 'is_nomcom_volunteer']
fields = required_fields + []
if request.method == 'POST':
# parameters:
# apikey:
# meeting
# name
# email
# reg_type (In Person, Remote, Hackathon Only)
# ticket_type (full_week, one_day, student)
#
data = {'attended': False, }
missing_fields = []
for item in fields:
value = request.POST.get(item, None)
if value is None and item in required_fields:
missing_fields.append(item)
data[item] = value
if missing_fields:
return err(400, "Missing parameters: %s" % ', '.join(missing_fields))
number = data['meeting']
try:
meeting = Meeting.objects.get(number=number)
except Meeting.DoesNotExist:
return err(400, "Invalid meeting value: '%s'" % (number, ))
reg_type = data['reg_type']
email = data['email']
try:
validate_email(email)
except ValidationError:
return err(400, "Invalid email value: '%s'" % (email, ))
if request.POST.get('cancelled', 'false') == 'true':
MeetingRegistration.objects.filter(
meeting_id=meeting.pk,
email=email,
reg_type=reg_type).delete()
return HttpResponse('OK', status=200, content_type='text/plain')
else:
object, created = MeetingRegistration.objects.get_or_create(
meeting_id=meeting.pk,
email=email,
reg_type=reg_type)
try:
# Update attributes
for key in set(data.keys())-set(['attended', 'apikey', 'meeting', 'email']):
if key == 'checkedin':
new = bool(data.get(key).lower() == 'true')
else:
new = data.get(key)
setattr(object, key, new)
person = Person.objects.filter(email__address=email)
if person.exists():
object.person = person.first()
object.save()
except ValueError as e:
return err(400, "Unexpected POST data: %s" % e)
response = "Accepted, New registration" if created else "Accepted, Updated registration"
if User.objects.filter(username__iexact=email).exists() or Email.objects.filter(address=email).exists():
pass
else:
send_account_creation_email(request, email)
response += ", Email sent"
# handle nomcom volunteer
if request.POST.get('is_nomcom_volunteer', 'false').lower() == 'true' and object.person:
try:
nomcom = NomCom.objects.get(is_accepting_volunteers=True)
except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned):
nomcom = None
if nomcom:
Volunteer.objects.get_or_create(
nomcom=nomcom,
person=object.person,
defaults={
"affiliation": data["affiliation"],
"origin": "registration"
}
)
return HttpResponse(response, status=202, content_type='text/plain')
else:
return HttpResponse(status=405)
def version(request):
dumpdate = None
dumpinfo = DumpInfo.objects.order_by('-date').first()
if dumpinfo:
dumpdate = dumpinfo.date
if dumpinfo.tz != "UTC":
dumpdate = pytz.timezone(dumpinfo.tz).localize(dumpinfo.date.replace(tzinfo=None))
dumptime = dumpdate.strftime('%Y-%m-%d %H:%M:%S %z') if dumpinfo else None
# important libraries
__version_extra__ = {}
for lib in settings.ADVERTISE_VERSIONS:
__version_extra__[lib] = metadata_version(lib)
return HttpResponse(
json.dumps({
'version': ietf.__version__+ietf.__patch__,
'other': __version_extra__,
'dumptime': dumptime,
}),
content_type='application/json',
)
@require_api_key
@csrf_exempt
def app_auth(request, app: Literal["authortools", "bibxml"]):
return HttpResponse(
json.dumps({'success': True}),
content_type='application/json')
@requires_api_token
@csrf_exempt
def nfs_metrics(request):
with NamedTemporaryFile(dir=settings.NFS_METRICS_TMP_DIR,delete=False) as fp:
fp.close()
mark = datetime.datetime.now()
with open(fp.name, mode="w") as f:
f.write("whyioughta"*1024)
write_latency = (datetime.datetime.now() - mark).total_seconds()
mark = datetime.datetime.now()
with open(fp.name, "r") as f:
_=f.read()
read_latency = (datetime.datetime.now() - mark).total_seconds()
Path(f.name).unlink()
response=f'nfs_latency_seconds{{operation="write"}} {write_latency}\nnfs_latency_seconds{{operation="read"}} {read_latency}\n'
return HttpResponse(response)
def find_doc_for_rfcdiff(name, rev):
"""rfcdiff lookup heuristics
Returns a tuple with:
[0] - condition string
[1] - document found (or None)
[2] - historic version
[3] - revision actually found (may differ from :rev: input)
"""
found = fuzzy_find_documents(name, rev)
condition = 'no such document'
if found.documents.count() != 1:
return (condition, None, None, rev)
doc = found.documents.get()
if found.matched_rev is None or doc.rev == found.matched_rev:
condition = 'current version'
return (condition, doc, None, found.matched_rev)
else:
candidate = doc.history_set.filter(rev=found.matched_rev).order_by("-time").first()
if candidate:
condition = 'historic version'
return (condition, doc, candidate, found.matched_rev)
else:
condition = 'version dochistory not found'
return (condition, doc, None, found.matched_rev)
# This is a proof of concept of a service that would redirect to the current revision
# def rfcdiff_latest(request, name, rev=None):
# condition, doc, history = find_doc_for_rfcdiff(name, rev)
# if not doc:
# raise Http404
# if history:
# return redirect(history.get_href())
# else:
# return redirect(doc.get_href())
HAS_TOMBSTONE = [
2821, 2822, 2873, 2919, 2961, 3023, 3029, 3031, 3032, 3033, 3034, 3035, 3036,
3037, 3038, 3042, 3044, 3050, 3052, 3054, 3055, 3056, 3057, 3059, 3060, 3061,
3062, 3063, 3064, 3067, 3068, 3069, 3070, 3071, 3072, 3073, 3074, 3075, 3076,
3077, 3078, 3080, 3081, 3082, 3084, 3085, 3086, 3087, 3088, 3089, 3090, 3094,
3095, 3096, 3097, 3098, 3101, 3102, 3103, 3104, 3105, 3106, 3107, 3108, 3109,
3110, 3111, 3112, 3113, 3114, 3115, 3116, 3117, 3118, 3119, 3120, 3121, 3123,
3124, 3126, 3127, 3128, 3130, 3131, 3132, 3133, 3134, 3135, 3136, 3137, 3138,
3139, 3140, 3141, 3142, 3143, 3144, 3145, 3147, 3149, 3150, 3151, 3152, 3153,
3154, 3155, 3156, 3157, 3158, 3159, 3160, 3161, 3162, 3163, 3164, 3165, 3166,
3167, 3168, 3169, 3170, 3171, 3172, 3173, 3174, 3176, 3179, 3180, 3181, 3182,
3183, 3184, 3185, 3186, 3187, 3188, 3189, 3190, 3191, 3192, 3193, 3194, 3197,
3198, 3201, 3202, 3203, 3204, 3205, 3206, 3207, 3208, 3209, 3210, 3211, 3212,
3213, 3214, 3215, 3216, 3217, 3218, 3220, 3221, 3222, 3224, 3225, 3226, 3227,
3228, 3229, 3230, 3231, 3232, 3233, 3234, 3235, 3236, 3237, 3238, 3240, 3241,
3242, 3243, 3244, 3245, 3246, 3247, 3248, 3249, 3250, 3253, 3254, 3255, 3256,
3257, 3258, 3259, 3260, 3261, 3262, 3263, 3264, 3265, 3266, 3267, 3268, 3269,
3270, 3271, 3272, 3273, 3274, 3275, 3276, 3278, 3279, 3280, 3281, 3282, 3283,
3284, 3285, 3286, 3287, 3288, 3289, 3290, 3291, 3292, 3293, 3294, 3295, 3296,
3297, 3298, 3301, 3302, 3303, 3304, 3305, 3307, 3308, 3309, 3310, 3311, 3312,
3313, 3315, 3317, 3318, 3319, 3320, 3321, 3322, 3323, 3324, 3325, 3326, 3327,
3329, 3330, 3331, 3332, 3334, 3335, 3336, 3338, 3340, 3341, 3342, 3343, 3346,
3348, 3349, 3351, 3352, 3353, 3354, 3355, 3356, 3360, 3361, 3362, 3363, 3364,
3366, 3367, 3368, 3369, 3370, 3371, 3372, 3374, 3375, 3377, 3378, 3379, 3383,
3384, 3385, 3386, 3387, 3388, 3389, 3390, 3391, 3394, 3395, 3396, 3397, 3398,
3401, 3402, 3403, 3404, 3405, 3406, 3407, 3408, 3409, 3410, 3411, 3412, 3413,
3414, 3415, 3416, 3417, 3418, 3419, 3420, 3421, 3422, 3423, 3424, 3425, 3426,
3427, 3428, 3429, 3430, 3431, 3433, 3434, 3435, 3436, 3437, 3438, 3439, 3440,
3441, 3443, 3444, 3445, 3446, 3447, 3448, 3449, 3450, 3451, 3452, 3453, 3454,
3455, 3458, 3459, 3460, 3461, 3462, 3463, 3464, 3465, 3466, 3467, 3468, 3469,
3470, 3471, 3472, 3473, 3474, 3475, 3476, 3477, 3480, 3481, 3483, 3485, 3488,
3494, 3495, 3496, 3497, 3498, 3501, 3502, 3503, 3504, 3505, 3506, 3507, 3508,
3509, 3511, 3512, 3515, 3516, 3517, 3518, 3520, 3521, 3522, 3523, 3524, 3525,
3527, 3529, 3530, 3532, 3533, 3534, 3536, 3537, 3538, 3539, 3541, 3543, 3544,
3545, 3546, 3547, 3548, 3549, 3550, 3551, 3552, 3555, 3556, 3557, 3558, 3559,
3560, 3562, 3563, 3564, 3565, 3568, 3569, 3570, 3571, 3572, 3573, 3574, 3575,
3576, 3577, 3578, 3579, 3580, 3581, 3582, 3583, 3584, 3588, 3589, 3590, 3591,
3592, 3593, 3594, 3595, 3597, 3598, 3601, 3607, 3609, 3610, 3612, 3614, 3615,
3616, 3625, 3627, 3630, 3635, 3636, 3637, 3638
]
def get_previous_url(name, rev=None):
'''Return previous url'''
condition, document, history, found_rev = find_doc_for_rfcdiff(name, rev)
previous_url = ''
if condition in ('historic version', 'current version'):
doc = history if history else document
previous_url = doc.get_href()
elif condition == 'version dochistory not found':
document.rev = found_rev
previous_url = document.get_href()
return previous_url
def rfcdiff_latest_json(request, name, rev=None):
response = dict()
condition, document, history, found_rev = find_doc_for_rfcdiff(name, rev)
if document and document.type_id == "rfc":
draft = document.came_from_draft()
if condition == 'no such document':
raise Http404
elif condition in ('historic version', 'current version'):
doc = history if history else document
if doc.type_id == "rfc":
response['content_url'] = doc.get_href()
response['name']=doc.name
if draft:
prev_rev = draft.rev
if doc.rfc_number in HAS_TOMBSTONE and prev_rev != '00':
prev_rev = f'{(int(draft.rev)-1):02d}'
response['previous'] = f'{draft.name}-{prev_rev}'
response['previous_url'] = get_previous_url(draft.name, prev_rev)
elif doc.type_id == "draft" and not found_rev and doc.relateddocument_set.filter(relationship_id="became_rfc").exists():
rfc = doc.related_that_doc("became_rfc")[0]
response['content_url'] = rfc.get_href()
response['name']=rfc.name
prev_rev = doc.rev
if rfc.rfc_number in HAS_TOMBSTONE and prev_rev != '00':
prev_rev = f'{(int(doc.rev)-1):02d}'
response['previous'] = f'{doc.name}-{prev_rev}'
response['previous_url'] = get_previous_url(doc.name, prev_rev)
else:
response['content_url'] = doc.get_href()
response['rev'] = doc.rev
response['name'] = doc.name
if doc.rev == '00':
replaces_docs = (history.doc if condition=='historic version' else doc).related_that_doc('replaces')
if replaces_docs:
replaces = replaces_docs[0]
response['previous'] = f'{replaces.name}-{replaces.rev}'
response['previous_url'] = get_previous_url(replaces.name, replaces.rev)
else:
match = re.search("-(rfc)?([0-9][0-9][0-9]+)bis(-.*)?$", name)
if match and match.group(2):
response['previous'] = f'rfc{match.group(2)}'
response['previous_url'] = get_previous_url(f'rfc{match.group(2)}')
else:
# not sure what to do if non-numeric values come back, so at least log it
log.assertion('doc.rev.isdigit()')
prev_rev = f'{(int(doc.rev)-1):02d}'
response['previous'] = f'{doc.name}-{prev_rev}'
response['previous_url'] = get_previous_url(doc.name, prev_rev)
elif condition == 'version dochistory not found':
response['warning'] = 'History for this version not found - these results are speculation'
response['name'] = document.name
response['rev'] = found_rev
document.rev = found_rev
response['content_url'] = document.get_href()
# not sure what to do if non-numeric values come back, so at least log it
log.assertion('found_rev.isdigit()')
if int(found_rev) > 0:
prev_rev = f'{(int(found_rev)-1):02d}'
response['previous'] = f'{document.name}-{prev_rev}'
response['previous_url'] = get_previous_url(document.name, prev_rev)
else:
match = re.search("-(rfc)?([0-9][0-9][0-9]+)bis(-.*)?$", name)
if match and match.group(2):
response['previous'] = f'rfc{match.group(2)}'
response['previous_url'] = get_previous_url(f'rfc{match.group(2)}')
if not response:
raise Http404
return HttpResponse(json.dumps(response), content_type='application/json')
@csrf_exempt
def directauth(request):
if request.method == "POST":
raw_data = request.POST.get("data", None)
if raw_data:
try:
data = json.loads(raw_data)
except json.decoder.JSONDecodeError:
data = None
if raw_data is None or data is None:
log.log("Request body is either missing or invalid")
return HttpResponse(json.dumps(dict(result="failure",reason="invalid post")), content_type='application/json')
authtoken = data.get('authtoken', None)
username = data.get('username', None)
password = data.get('password', None)
if any([item is None for item in (authtoken, username, password)]):
log.log("One or more mandatory fields are missing: authtoken, username, password")
return HttpResponse(json.dumps(dict(result="failure",reason="invalid post")), content_type='application/json')
if not is_valid_token("ietf.api.views.directauth", authtoken):
log.log("Auth token provided is invalid")
return HttpResponse(json.dumps(dict(result="failure",reason="invalid authtoken")), content_type='application/json')
user_query = User.objects.filter(username__iexact=username)
# Matching email would be consistent with auth everywhere else in the app, but until we can map users well
# in the imap server, people's annotations are associated with a very specific login.
# If we get a second user of this API, add an "allow_any_email" argument.
# Note well that we are using user.username, not what was passed to the API.
user_count = user_query.count()
if user_count == 1 and authenticate(username = user_query.first().username, password = password):
user = user_query.get()
if user_query.filter(person__isnull=True).count() == 1: # Can't inspect user.person direclty here
log.log(f"Direct auth success (personless user): {user.pk}:{user.username}")
else:
log.log(f"Direct auth success: {user.pk}:{user.person.plain_name()}")
return HttpResponse(json.dumps(dict(result="success")), content_type='application/json')
log.log(f"Direct auth failure: {username} ({user_count} user(s) found)")
return HttpResponse(json.dumps(dict(result="failure", reason="authentication failed")), content_type='application/json')
else:
log.log(f"Request must be POST: {request.method} received")
return HttpResponse(status=405)
@requires_api_token
@csrf_exempt
def draft_aliases(request):
if request.method == "GET":
return JsonResponse(
{
"aliases": [
{
"alias": alias,
"domains": ["ietf"],
"addresses": address_list,
}
for alias, address_list in DraftAliasGenerator()
]
}
)
return HttpResponse(status=405)
@requires_api_token
@csrf_exempt
def group_aliases(request):
if request.method == "GET":
return JsonResponse(
{
"aliases": [
{
"alias": alias,
"domains": domains,
"addresses": address_list,
}
for alias, domains, address_list in GroupAliasGenerator()
]
}
)
return HttpResponse(status=405)
@requires_api_token
@csrf_exempt
def active_email_list(request):
if request.method == "GET":
return JsonResponse(
{
"addresses": list(Email.objects.filter(active=True).values_list("address", flat=True)),
}
)
return HttpResponse(status=405)
@requires_api_token
def role_holder_addresses(request):
if request.method == "GET":
return JsonResponse(
{
"addresses": list(
role_holder_emails()
.order_by("address")
.values_list("address", flat=True)
)
}
)
return HttpResponse(status=405)
_response_email_json_validator = jsonschema.Draft202012Validator(
schema={
"type": "object",
"properties": {
"dest": {
"type": "string",
},
"message": {
"type": "string", # base64-encoded mail message
},
},
"required": ["dest", "message"],
}
)
class EmailIngestionError(Exception):
"""Exception indicating ingestion failed"""
def __init__(
self,
msg="Message rejected",
*,
email_body: Optional[str] = None,
email_recipients: Optional[Iterable[str]] = None,
email_attach_traceback=False,
email_original_message: Optional[bytes]=None,
):
self.msg = msg
self.email_body = email_body
self.email_subject = msg
self.email_recipients = email_recipients
self.email_attach_traceback = email_attach_traceback
self.email_original_message = email_original_message
self.email_from = settings.SERVER_EMAIL
@staticmethod
def _summarize_error(error):
frame = extract_tb(error.__traceback__)[-1]
return dedent(f"""\
Error details:
Exception type: {type(error).__module__}.{type(error).__name__}
File: {frame.filename}
Line: {frame.lineno}""")
def as_emailmessage(self) -> Optional[EmailMessage]:
"""Generate an EmailMessage to report an error"""
if self.email_body is None:
return None
error = self if self.__cause__ is None else self.__cause__
format_values = dict(
error=error,
error_summary=self._summarize_error(error),
)
msg = EmailMessage()
if self.email_recipients is None:
msg["To"] = tuple(adm[1] for adm in settings.ADMINS)
else:
msg["To"] = self.email_recipients
msg["From"] = self.email_from
msg["Subject"] = self.msg
msg.set_content(
self.email_body.format(**format_values)
)
if self.email_attach_traceback:
msg.add_attachment(
"".join(format_exception(None, error, error.__traceback__)),
filename="traceback.txt",
)
if self.email_original_message is not None:
# Attach incoming message if it was provided. Send as a generic media
# type because we don't know for sure that it was actually a valid
# message.
msg.add_attachment(
self.email_original_message,
'application', 'octet-stream', # media type
filename='original-message',
)
return msg
def ingest_email_handler(request, test_mode=False):
"""Ingest incoming email - handler
Returns a 4xx or 5xx status code if the HTTP request was invalid or something went
wrong while processing it. If the request was valid, returns a 200. This may or may
not indicate that the message was accepted.
If test_mode is true, actual processing of a valid message will be skipped. In this
mode, a valid request with a valid destination will be treated as accepted. The
"bad_dest" error may still be returned.
"""
def _http_err(code, text):
return HttpResponse(text, status=code, content_type="text/plain")
def _api_response(result):
return JsonResponse(data={"result": result})
if request.method != "POST":
return _http_err(405, "Method not allowed")
if request.content_type != "application/json":
return _http_err(415, "Content-Type must be application/json")
# Validate
try:
payload = json.loads(request.body)
_response_email_json_validator.validate(payload)
except json.decoder.JSONDecodeError as err:
return _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
except jsonschema.exceptions.ValidationError as err:
return _http_err(400, f"JSON schema error at {err.json_path}: {err.message}")
except Exception:
return _http_err(400, "Invalid request format")
try:
message = base64.b64decode(payload["message"], validate=True)
except binascii.Error:
return _http_err(400, "Invalid message: bad base64 encoding")
dest = payload["dest"]
valid_dest = False
try:
if dest == "iana-review":
valid_dest = True
if not test_mode:
iana_ingest_review_email(message)
elif dest == "ipr-response":
valid_dest = True
if not test_mode:
ipr_ingest_response_email(message)
elif dest.startswith("nomcom-feedback-"):
maybe_year = dest[len("nomcom-feedback-"):]
if maybe_year.isdecimal():
valid_dest = True
if not test_mode:
nomcom_ingest_feedback_email(message, int(maybe_year))
except EmailIngestionError as err:
error_email = err.as_emailmessage()
if error_email is not None:
with suppress(Exception): # send_smtp logs its own exceptions, ignore them here
send_smtp(error_email)
return _api_response("bad_msg")
if not valid_dest:
return _api_response("bad_dest")
return _api_response("ok")
@requires_api_token
@csrf_exempt
def ingest_email(request):
"""Ingest incoming email
Hands off to ingest_email_handler() with test_mode=False. This allows @requires_api_token to
give the test endpoint a distinct token from the real one.
"""
return ingest_email_handler(request, test_mode=False)
@requires_api_token
@csrf_exempt
def ingest_email_test(request):
"""Ingest incoming email test endpoint
Hands off to ingest_email_handler() with test_mode=True. This allows @requires_api_token to
give the test endpoint a distinct token from the real one.
"""
return ingest_email_handler(request, test_mode=True)