diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 7d51c6a4cf..b586bd6a65 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -54,7 +54,6 @@ admin.site.site_header = "InvenTree Admin" apipatterns = [ url(r'^barcode/', include(barcode_api_urls)), - url(r'^common/', include(common_api_urls)), url(r'^part/', include(part_api_urls)), url(r'^bom/', include(bom_api_urls)), url(r'^company/', include(company_api_urls)), @@ -70,6 +69,9 @@ apipatterns = [ # Plugin endpoints url(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), + # Webhook enpoint + path('', include(common_api_urls)), + # InvenTree information endpoint url(r'^$', InfoView.as_view(), name='api-inventree-info'), diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index 1eda18e869..50954924ff 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from .models import InvenTreeSetting, InvenTreeUserSetting +from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint class SettingsAdmin(ImportExportModelAdmin): @@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin): list_display = ('key', 'value', 'user', ) +class WebhookAdmin(ImportExportModelAdmin): + + list_display = ('endpoint_id', 'name', 'active', 'user') + + admin.site.register(InvenTreeSetting, SettingsAdmin) admin.site.register(InvenTreeUserSetting, UserSettingsAdmin) +admin.site.register(WebhookEndpoint, WebhookAdmin) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 8a2dfbd6a7..efde6e863e 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -5,5 +5,122 @@ Provides a JSON API for common components. # -*- coding: utf-8 -*- from __future__ import unicode_literals +import json +from secrets import compare_digest + +from django.utils.decorators import method_decorator +from django.urls import path +from django.views.decorators.csrf import csrf_exempt + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied, NotFound, NotAcceptable + +from .models import WebhookEndpoint + + +class CsrfExemptMixin(object): + """ + Exempts the view from CSRF requirements. + """ + + @method_decorator(csrf_exempt) + def dispatch(self, *args, **kwargs): + return super(CsrfExemptMixin, self).dispatch(*args, **kwargs) + + +class VerificationMethod: + NONE = 0 + TOKEN = 1 + HMAC = 2 + + +class WebhookView(CsrfExemptMixin, APIView): + """ + Endpoint for receiving webhoks. + """ + authentication_classes = [] + permission_classes = [] + model_class = WebhookEndpoint + + # Token + TOKEN_NAME = "Token" + VERIFICATION_METHOD = VerificationMethod.NONE + + MESSAGE_OK = "Message was received." + MESSAGE_TOKEN_ERROR = "Incorrect token in header." + + def post(self, request, endpoint, *args, **kwargs): + self.init(request, *args, **kwargs) + # get webhook definition + self.get_webhook(endpoint, *args, **kwargs) + # check headers + headers = request.headers + self.validate_token(headers) + + # process data + try: + payload = json.loads(request.body) + except json.decoder.JSONDecodeError as error: + raise NotAcceptable(error.msg) + self.save_data(payload, headers, request) + self.process_payload(payload, headers, request) + + # return results + return_kwargs = self.get_result(payload, headers, request) + return Response(**return_kwargs) + + # To be overridden + def init(self, request, *args, **kwargs): + self.token = '' + self.verify = self.VERIFICATION_METHOD + + def get_webhook(self, endpoint): + try: + webhook = self.model_class.objects.get(endpoint_id=endpoint) + self.webhook = webhook + return self.process_webhook() + except self.model_class.DoesNotExist: + raise NotFound() + + def process_webhook(self): + if self.webhook.token: + self.token = self.webhook.token + self.verify = VerificationMethod.TOKEN + return True + + def validate_token(self, headers): + token = headers.get(self.TOKEN_NAME, "") + + # no token + if self.verify == VerificationMethod.NONE: + return True + + # static token + elif self.verify == VerificationMethod.TOKEN: + if not compare_digest(token, self.token): + raise PermissionDenied(self.MESSAGE_TOKEN_ERROR) + return True + + # hmac token + elif self.verify == VerificationMethod.HMAC: + # TODO write check + return True + + def save_data(self, payload, headers=None, request=None): + # TODO safe data + return + + def process_payload(self, payload, headers=None, request=None): + return + + def get_result(self, payload, headers=None, request=None): + context = {} + context['data'] = {'message': self.MESSAGE_OK} + context['status'] = 200 + return context + + common_api_urls = [ + path('webhook//', WebhookView.as_view(), name='api-webhook'), ] diff --git a/InvenTree/common/migrations/0012_webhookendpoint.py b/InvenTree/common/migrations/0012_webhookendpoint.py new file mode 100644 index 0000000000..b3cf6bce2f --- /dev/null +++ b/InvenTree/common/migrations/0012_webhookendpoint.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.4 on 2021-09-12 12:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('common', '0011_auto_20210722_2114'), + ] + + operations = [ + migrations.CreateModel( + name='WebhookEndpoint', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('endpoint_id', models.CharField(default=uuid.uuid4, editable=False, help_text='Endpoint at which this webhook is received', max_length=255, verbose_name='Endpoint')), + ('name', models.CharField(blank=True, help_text='Name for this webhook', max_length=255, null=True, verbose_name='Name')), + ('active', models.BooleanField(default=True, help_text='Is this webhook active', verbose_name='Active')), + ('token', models.CharField(blank=True, default=uuid.uuid4, help_text='Token for access', max_length=255, null=True, verbose_name='Token')), + ('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + ), + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index aed6f2bf14..df64cd5cbc 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -9,6 +9,7 @@ from __future__ import unicode_literals import os import decimal import math +import uuid from django.db import models, transaction from django.contrib.auth.models import User @@ -1165,3 +1166,52 @@ class ColorTheme(models.Model): return True return False + + +class WebhookEndpoint(models.Model): + """ Defines a Webhook entdpoint + + Attributes: + endpoint_id: Path to the webhook, + name: Name of the webhook, + active: Is this webhook active?, + user: User associated with webhook, + token: Token for sending a webhook, + """ + + endpoint_id = models.CharField( + max_length=255, + verbose_name=_('Endpoint'), + help_text=_('Endpoint at which this webhook is received'), + default=uuid.uuid4, + editable=False, + ) + + name = models.CharField( + max_length=255, + blank=True, null=True, + verbose_name=_('Name'), + help_text=_('Name for this webhook') + ) + + active = models.BooleanField( + default=True, + verbose_name=_('Active'), + help_text=_('Is this webhook active') + ) + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('User'), + help_text=_('User'), + ) + + token = models.CharField( + max_length=255, + blank=True, null=True, + verbose_name=_('Token'), + help_text=_('Token for access'), + default=uuid.uuid4, + )