diff --git a/.env.example b/.env.example index 082aa753b..b98adf171 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,8 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 +# Set it to 0, to disable it +ENABLE_WEBHOOK=1 + +# Set it to 0, to disable it +ENABLE_API=1 \ No newline at end of file diff --git a/apiserver/.env.example b/apiserver/.env.example index d589e3d0a..d0b4013a8 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -70,5 +70,12 @@ ENABLE_MAGIC_LINK_LOGIN="0" # Email redirections and minio domain settings WEB_URL="http://localhost" +# Set it to 0, to disable it +ENABLE_WEBHOOK=1 + +# Set it to 0, to disable it +ENABLE_API=1 + # Gunicorn Workers -GUNICORN_WORKERS=2 \ No newline at end of file +GUNICORN_WORKERS=2 + diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py index 9164a5529..2298f3442 100644 --- a/apiserver/plane/api/permissions/__init__.py +++ b/apiserver/plane/api/permissions/__init__.py @@ -1,2 +1,17 @@ -from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission, WorkspaceUserPermission -from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission + +from .workspace import ( + WorkSpaceBasePermission, + WorkspaceOwnerPermission, + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceViewerPermission, + WorkspaceUserPermission, +) +from .project import ( + ProjectBasePermission, + ProjectEntityPermission, + ProjectMemberPermission, + ProjectLitePermission, +) + + diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index b2f5753a5..33bcab31c 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -45,6 +45,18 @@ class WorkSpaceBasePermission(BasePermission): ).exists() +class WorkspaceOwnerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + role=Owner, + ).exists() + + class WorkSpaceAdminPermission(BasePermission): def has_permission(self, request, view): if request.user.is_anonymous: @@ -93,10 +105,12 @@ class WorkspaceViewerPermission(BasePermission): class WorkspaceUserPermission(BasePermission): - def has_permission(self, request, view): + if request.user.is_anonymous: + return False + return WorkspaceMember.objects.filter( member=request.user, workspace__slug=view.workspace_slug, is_active=True, - ) + ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index f1a7de3b8..901f0bc01 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -71,7 +71,7 @@ from .module import ( ModuleFavoriteSerializer, ) -from .api_token import APITokenSerializer +from .api import APITokenSerializer, APITokenReadSerializer from .integration import ( IntegrationSerializer, @@ -100,3 +100,5 @@ from .analytic import AnalyticViewSerializer from .notification import NotificationSerializer from .exporter import ExporterHistorySerializer + +from .webhook import WebhookSerializer, WebhookLogSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/api.py b/apiserver/plane/api/serializers/api.py new file mode 100644 index 000000000..08bb747d9 --- /dev/null +++ b/apiserver/plane/api/serializers/api.py @@ -0,0 +1,31 @@ +from .base import BaseSerializer +from plane.db.models import APIToken, APIActivityLog + + +class APITokenSerializer(BaseSerializer): + + class Meta: + model = APIToken + fields = "__all__" + read_only_fields = [ + "token", + "expired_at", + "created_at", + "updated_at", + "workspace", + "user", + ] + + +class APITokenReadSerializer(BaseSerializer): + + class Meta: + model = APIToken + exclude = ('token',) + + +class APIActivityLogSerializer(BaseSerializer): + + class Meta: + model = APIActivityLog + fields = "__all__" diff --git a/apiserver/plane/api/serializers/api_token.py b/apiserver/plane/api/serializers/api_token.py deleted file mode 100644 index 9c363f895..000000000 --- a/apiserver/plane/api/serializers/api_token.py +++ /dev/null @@ -1,14 +0,0 @@ -from .base import BaseSerializer -from plane.db.models import APIToken - - -class APITokenSerializer(BaseSerializer): - class Meta: - model = APIToken - fields = [ - "label", - "user", - "user_type", - "workspace", - "created_at", - ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index ca42dc8f7..9ecae555c 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -112,7 +112,7 @@ class ProjectListSerializer(DynamicBaseSerializer): "member__display_name", "member__avatar", ) - return project_members + return list(project_members) class Meta: model = Project diff --git a/apiserver/plane/api/serializers/webhook.py b/apiserver/plane/api/serializers/webhook.py new file mode 100644 index 000000000..351b6fe7d --- /dev/null +++ b/apiserver/plane/api/serializers/webhook.py @@ -0,0 +1,30 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import DynamicBaseSerializer +from plane.db.models import Webhook, WebhookLog +from plane.db.models.webhook import validate_domain, validate_schema + +class WebhookSerializer(DynamicBaseSerializer): + url = serializers.URLField(validators=[validate_schema, validate_domain]) + + class Meta: + model = Webhook + fields = "__all__" + read_only_fields = [ + "workspace", + "secret_key", + ] + + +class WebhookLogSerializer(DynamicBaseSerializer): + + class Meta: + model = WebhookLog + fields = "__all__" + read_only_fields = [ + "workspace", + "webhook" + ] + diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index 957dac24e..1e3c1cbca 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -19,6 +19,12 @@ from .state import urlpatterns as state_urls from .user import urlpatterns as user_urls from .views import urlpatterns as view_urls from .workspace import urlpatterns as workspace_urls +from .api import urlpatterns as api_urls +from .webhook import urlpatterns as webhook_urls + + +# Django imports +from django.conf import settings urlpatterns = [ @@ -44,3 +50,9 @@ urlpatterns = [ *view_urls, *workspace_urls, ] + +if settings.ENABLE_WEBHOOK: + urlpatterns += webhook_urls + +if settings.ENABLE_API: + urlpatterns += api_urls diff --git a/apiserver/plane/api/urls/api.py b/apiserver/plane/api/urls/api.py new file mode 100644 index 000000000..1a2862045 --- /dev/null +++ b/apiserver/plane/api/urls/api.py @@ -0,0 +1,17 @@ +from django.urls import path +from plane.api.views import ApiTokenEndpoint + +urlpatterns = [ + # API Tokens + path( + "workspaces//api-tokens/", + ApiTokenEndpoint.as_view(), + name="api-tokens", + ), + path( + "workspaces//api-tokens//", + ApiTokenEndpoint.as_view(), + name="api-tokens", + ), + ## End API Tokens +] diff --git a/apiserver/plane/api/urls/webhook.py b/apiserver/plane/api/urls/webhook.py new file mode 100644 index 000000000..74a8da759 --- /dev/null +++ b/apiserver/plane/api/urls/webhook.py @@ -0,0 +1,31 @@ +from django.urls import path + +from plane.api.views import ( + WebhookEndpoint, + WebhookLogsEndpoint, + WebhookSecretRegenerateEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//webhooks/", + WebhookEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhooks//", + WebhookEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhooks//regenerate/", + WebhookSecretRegenerateEndpoint.as_view(), + name="webhooks", + ), + path( + "workspaces//webhook-logs//", + WebhookLogsEndpoint.as_view(), + name="webhooks", + ), +] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 78c7ef341..787dfb3e2 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -23,7 +23,7 @@ from .user import ( from .oauth import OauthEndpoint -from .base import BaseAPIView, BaseViewSet +from .base import BaseAPIView, BaseViewSet, WebhookMixin from .workspace import ( WorkSpaceViewSet, @@ -115,7 +115,7 @@ from .module import ( ModuleFavoriteViewSet, ) -from .api_token import ApiTokenEndpoint +from .api import ApiTokenEndpoint from .integration import ( WorkspaceIntegrationViewSet, @@ -172,3 +172,5 @@ from .notification import ( from .exporter import ExportIssuesEndpoint from .config import ConfigurationEndpoint + +from .webhook import WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint diff --git a/apiserver/plane/api/views/api.py b/apiserver/plane/api/views/api.py new file mode 100644 index 000000000..59da6d3c4 --- /dev/null +++ b/apiserver/plane/api/views/api.py @@ -0,0 +1,78 @@ +# Python import +from uuid import uuid4 + +# Third party +from rest_framework.response import Response +from rest_framework import status + +# Module import +from .base import BaseAPIView +from plane.db.models import APIToken, Workspace +from plane.api.serializers import APITokenSerializer, APITokenReadSerializer +from plane.api.permissions import WorkspaceOwnerPermission + + +class ApiTokenEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def post(self, request, slug): + label = request.data.get("label", str(uuid4().hex)) + description = request.data.get("description", "") + workspace = Workspace.objects.get(slug=slug) + expired_at = request.data.get("expired_at", None) + + # Check the user type + user_type = 1 if request.user.is_bot else 0 + + api_token = APIToken.objects.create( + label=label, + description=description, + user=request.user, + workspace=workspace, + user_type=user_type, + expired_at=expired_at, + ) + + serializer = APITokenSerializer(api_token) + # Token will be only visible while creating + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + ) + + def get(self, request, slug, pk=None): + if pk == None: + api_tokens = APIToken.objects.filter( + user=request.user, workspace__slug=slug + ) + serializer = APITokenReadSerializer(api_tokens, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + api_tokens = APIToken.objects.get( + user=request.user, workspace__slug=slug, pk=pk + ) + serializer = APITokenReadSerializer(api_tokens) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, slug, pk): + api_token = APIToken.objects.get( + workspace__slug=slug, + user=request.user, + pk=pk, + ) + api_token.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def patch(self, request, slug, pk): + api_token = APIToken.objects.get( + workspace__slug=slug, + user=request.user, + pk=pk, + ) + serializer = APITokenSerializer(api_token, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/api_token.py b/apiserver/plane/api/views/api_token.py deleted file mode 100644 index 2253903a9..000000000 --- a/apiserver/plane/api/views/api_token.py +++ /dev/null @@ -1,47 +0,0 @@ -# Python import -from uuid import uuid4 - -# Third party -from rest_framework.response import Response -from rest_framework import status -from sentry_sdk import capture_exception - -# Module import -from .base import BaseAPIView -from plane.db.models import APIToken -from plane.api.serializers import APITokenSerializer - - -class ApiTokenEndpoint(BaseAPIView): - def post(self, request): - label = request.data.get("label", str(uuid4().hex)) - workspace = request.data.get("workspace", False) - - if not workspace: - return Response( - {"error": "Workspace is required"}, status=status.HTTP_200_OK - ) - - api_token = APIToken.objects.create( - label=label, user=request.user, workspace_id=workspace - ) - - serializer = APITokenSerializer(api_token) - # Token will be only vissible while creating - return Response( - {"api_token": serializer.data, "token": api_token.token}, - status=status.HTTP_201_CREATED, - ) - - - def get(self, request): - api_tokens = APIToken.objects.filter(user=request.user) - serializer = APITokenSerializer(api_tokens, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - - def delete(self, request, pk): - api_token = APIToken.objects.get(pk=pk) - api_token.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 7ab660e81..71f9c1842 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,5 +1,6 @@ # Python imports import zoneinfo +import json # Django imports from django.urls import resolve @@ -7,6 +8,7 @@ from django.conf import settings from django.utils import timezone from django.db import IntegrityError from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.serializers.json import DjangoJSONEncoder # Third part imports from rest_framework import status @@ -22,6 +24,7 @@ from django_filters.rest_framework import DjangoFilterBackend # Module imports from plane.utils.paginator import BasePaginator +from plane.bgtasks.webhook_task import send_webhook class TimezoneMixin: @@ -29,6 +32,7 @@ class TimezoneMixin: This enables timezone conversion according to the user set timezone """ + def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) if request.user.is_authenticated: @@ -37,8 +41,29 @@ class TimezoneMixin: timezone.deactivate() -class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): +class WebhookMixin: + webhook_event = None + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + + if ( + self.webhook_event + and self.request.method in ["POST", "PATCH", "DELETE"] + and response.status_code in [200, 201, 204] + and settings.ENABLE_WEBHOOK + ): + send_webhook.delay( + event=self.webhook_event, + event_data=json.dumps(response.data, cls=DjangoJSONEncoder), + action=self.request.method, + slug=self.workspace_slug, + ) + + return response + + +class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None permission_classes = [ @@ -60,7 +85,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): except Exception as e: capture_exception(e) raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) - + def handle_exception(self, exc): """ Handle any exception that occurs, by returning an appropriate response, @@ -71,18 +96,30 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): return response except Exception as e: if isinstance(e, IntegrityError): - return Response({"error": "The payload is not valid"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(e, ValidationError): - return Response({"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(e, ObjectDoesNotExist): model_name = str(exc).split(" matching query does not exist.")[0] - return Response({"error": f"{model_name} does not exist."}, status=status.HTTP_404_NOT_FOUND) - + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + if isinstance(e, KeyError): capture_exception(e) - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": f"key {e} does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) print(e) if settings.DEBUG else print("Server Error") capture_exception(e) @@ -99,8 +136,8 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): print( f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" ) - return response + return response except Exception as exc: response = self.handle_exception(exc) return exc @@ -120,7 +157,6 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): class BaseAPIView(TimezoneMixin, APIView, BasePaginator): - permission_classes = [ IsAuthenticated, ] @@ -139,7 +175,6 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): queryset = backend().filter_queryset(self.request, queryset, self) return queryset - def handle_exception(self, exc): """ Handle any exception that occurs, by returning an appropriate response, @@ -150,19 +185,29 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): return response except Exception as e: if isinstance(e, IntegrityError): - return Response({"error": "The payload is not valid"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "The payload is not valid"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(e, ValidationError): - return Response({"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST) - + return Response( + {"error": "Please provide valid detail"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if isinstance(e, ObjectDoesNotExist): model_name = str(exc).split(" matching query does not exist.")[0] - return Response({"error": f"{model_name} does not exist."}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"error": f"{model_name} does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) if isinstance(e, KeyError): return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) - - print(e) if settings.DEBUG else print("Server Error") + + if settings.DEBUG: + print(e) capture_exception(e) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 21defcc13..2a62ab8ac 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -23,7 +23,7 @@ from rest_framework import status from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet, BaseAPIView +from . import BaseViewSet, BaseAPIView, WebhookMixin from plane.api.serializers import ( CycleSerializer, CycleIssueSerializer, @@ -48,9 +48,10 @@ from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot -class CycleViewSet(BaseViewSet): +class CycleViewSet(WebhookMixin, BaseViewSet): serializer_class = CycleSerializer model = Cycle + webhook_event = "cycle" permission_classes = [ ProjectEntityPermission, ] @@ -499,10 +500,10 @@ class CycleViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleIssueViewSet(BaseViewSet): +class CycleIssueViewSet(WebhookMixin, BaseViewSet): serializer_class = CycleIssueSerializer model = CycleIssue - + webhook_event = "cycle" permission_classes = [ ProjectEntityPermission, ] diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 302a49035..072fabe0e 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -33,7 +33,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet, BaseAPIView +from . import BaseViewSet, BaseAPIView, WebhookMixin from plane.api.serializers import ( IssueCreateSerializer, IssueActivitySerializer, @@ -84,7 +84,7 @@ from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters -class IssueViewSet(BaseViewSet): +class IssueViewSet(WebhookMixin, BaseViewSet): def get_serializer_class(self): return ( IssueCreateSerializer @@ -93,6 +93,7 @@ class IssueViewSet(BaseViewSet): ) model = Issue + webhook_event = "issue" permission_classes = [ ProjectEntityPermission, ] @@ -594,9 +595,10 @@ class IssueActivityEndpoint(BaseAPIView): return Response(result_list, status=status.HTTP_200_OK) -class IssueCommentViewSet(BaseViewSet): +class IssueCommentViewSet(WebhookMixin, BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment + webhook_event = "issue-comment" permission_classes = [ ProjectLitePermission, ] diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 6c2088922..173526a2c 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -15,7 +15,7 @@ from rest_framework import status from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet +from . import BaseViewSet, WebhookMixin from plane.api.serializers import ( ModuleWriteSerializer, ModuleSerializer, @@ -41,11 +41,12 @@ from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot -class ModuleViewSet(BaseViewSet): +class ModuleViewSet(WebhookMixin, BaseViewSet): model = Module permission_classes = [ ProjectEntityPermission, ] + webhook_event = "module" def get_serializer_class(self): return ( diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 7833d051f..08c7fee4d 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -26,7 +26,7 @@ from rest_framework import serializers from rest_framework.permissions import AllowAny # Module imports -from .base import BaseViewSet, BaseAPIView +from .base import BaseViewSet, BaseAPIView, WebhookMixin from plane.api.serializers import ( ProjectSerializer, ProjectListSerializer, @@ -67,9 +67,10 @@ from plane.db.models import ( from plane.bgtasks.project_invitation_task import project_invitation -class ProjectViewSet(BaseViewSet): +class ProjectViewSet(WebhookMixin, BaseViewSet): serializer_class = ProjectSerializer model = Project + webhook_event = "project" permission_classes = [ ProjectBasePermission, diff --git a/apiserver/plane/api/views/webhook.py b/apiserver/plane/api/views/webhook.py new file mode 100644 index 000000000..91a2f6729 --- /dev/null +++ b/apiserver/plane/api/views/webhook.py @@ -0,0 +1,130 @@ +# Django imports +from django.db import IntegrityError + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import Webhook, WebhookLog, Workspace +from plane.db.models.webhook import generate_token +from .base import BaseAPIView +from plane.api.permissions import WorkspaceOwnerPermission +from plane.api.serializers import WebhookSerializer, WebhookLogSerializer + + +class WebhookEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def post(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + try: + serializer = WebhookSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "URL already exists for the workspace"}, + status=status.HTTP_410_GONE, + ) + raise IntegrityError + + def get(self, request, slug, pk=None): + if pk == None: + webhooks = Webhook.objects.filter(workspace__slug=slug) + serializer = WebhookSerializer( + webhooks, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + many=True, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + serializer = WebhookSerializer( + webhook, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, pk): + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + serializer = WebhookSerializer( + webhook, + data=request.data, + partial=True, + fields=( + "id", + "url", + "is_active", + "created_at", + "updated_at", + "project", + "issue", + "cycle", + "module", + "issue_comment", + ), + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, pk): + webhook = Webhook.objects.get(pk=pk, workspace__slug=slug) + webhook.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WebhookSecretRegenerateEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def post(self, request, slug, pk): + webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) + webhook.secret_key = generate_token() + webhook.save() + serializer = WebhookSerializer(webhook) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WebhookLogsEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceOwnerPermission, + ] + + def get(self, request, slug, webhook_id): + webhook_logs = WebhookLog.objects.filter( + workspace__slug=slug, webhook_id=webhook_id + ) + serializer = WebhookLogSerializer(webhook_logs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/authentication/__init__.py b/apiserver/plane/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/authentication/api_authentication.py b/apiserver/plane/authentication/api_authentication.py new file mode 100644 index 000000000..ddabb4132 --- /dev/null +++ b/apiserver/plane/authentication/api_authentication.py @@ -0,0 +1,47 @@ +# Django imports +from django.utils import timezone +from django.db.models import Q + +# Third party imports +from rest_framework import authentication +from rest_framework.exceptions import AuthenticationFailed + +# Module imports +from plane.db.models import APIToken + + +class APIKeyAuthentication(authentication.BaseAuthentication): + """ + Authentication with an API Key + """ + + www_authenticate_realm = "api" + media_type = "application/json" + auth_header_name = "X-Api-Key" + + def get_api_token(self, request): + return request.headers.get(self.auth_header_name) + + def validate_api_token(self, token): + try: + api_token = APIToken.objects.get( + Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + token=token, + is_active=True, + ) + except APIToken.DoesNotExist: + raise AuthenticationFailed("Given API token is not valid") + + # save api token last used + api_token.last_used = timezone.now() + api_token.save(update_fields=["last_used"]) + return (api_token.user, api_token.token) + + def authenticate(self, request): + token = self.get_api_token(request=request) + if not token: + return None + + # Validate the API token + user, token = self.validate_api_token(token) + return user, token diff --git a/apiserver/plane/authentication/apps.py b/apiserver/plane/authentication/apps.py new file mode 100644 index 000000000..de6100e0f --- /dev/null +++ b/apiserver/plane/authentication/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = "plane.authentication" diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py new file mode 100644 index 000000000..57f94dc03 --- /dev/null +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -0,0 +1,139 @@ +import requests +import uuid +import hashlib +import json + +# Django imports +from django.conf import settings + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception + +from plane.db.models import Webhook, WebhookLog + + +@shared_task( + bind=True, + autoretry_for=(requests.RequestException,), + retry_backoff=600, + max_retries=5, + retry_jitter=True, +) +def webhook_task(self, webhook, slug, event, event_data, action): + try: + webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + + headers = { + "Content-Type": "application/json", + "User-Agent": "Autopilot", + "X-Plane-Delivery": str(uuid.uuid4()), + "X-Plane-Event": event, + } + + # Your secret key + if webhook.secret_key: + # Concatenate the data and the secret key + message = event_data + webhook.secret_key + + # Create a SHA-256 hash of the message + sha256 = hashlib.sha256() + sha256.update(message.encode("utf-8")) + signature = sha256.hexdigest() + headers["X-Plane-Signature"] = signature + + event_data = json.loads(event_data) if event_data is not None else None + + action = { + "POST": "create", + "PATCH": "update", + "PUT": "update", + "DELETE": "delete", + }.get(action, action) + + payload = { + "event": event, + "action": action, + "webhook_id": str(webhook.id), + "workspace_id": str(webhook.workspace_id), + "data": event_data, + } + + # Send the webhook event + response = requests.post( + webhook.url, + headers=headers, + json=payload, + timeout=30, + ) + + # Log the webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=str(response.status_code), + response_headers=str(response.headers), + response_body=str(response.text), + retry_count=str(self.request.retries), + ) + + except requests.RequestException as e: + # Log the failed webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=500, + response_headers="", + response_body=str(e), + retry_count=str(self.request.retries), + ) + + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + return + raise requests.RequestException() + + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return + + +@shared_task() +def send_webhook(event, event_data, action, slug): + try: + webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) + + if event == "project": + webhooks = webhooks.filter(project=True) + + if event == "issue": + webhooks = webhooks.filter(issue=True) + + if event == "module": + webhooks = webhooks.filter(module=True) + + if event == "cycle": + webhooks = webhooks.filter(cycle=True) + + if event == "issue-comment": + webhooks = webhooks.filter(issue_comment=True) + + for webhook in webhooks: + webhook_task.delay(webhook.id, slug, event, event_data, action) + + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py index 500bc3b28..03eaeacd7 100644 --- a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py +++ b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import plane.db.models.api_token +import plane.db.models.api import uuid @@ -40,8 +40,8 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('token', models.CharField(default=plane.db.models.api_token.generate_token, max_length=255, unique=True)), - ('label', models.CharField(default=plane.db.models.api_token.generate_label_token, max_length=255)), + ('token', models.CharField(default=plane.db.models.api.generate_token, max_length=255, unique=True)), + ('label', models.CharField(default=plane.db.models.api.generate_label_token, max_length=255)), ('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), diff --git a/apiserver/plane/db/migrations/0047_auto_20231030_0833.py b/apiserver/plane/db/migrations/0047_auto_20231030_0833.py new file mode 100644 index 000000000..0005e683c --- /dev/null +++ b/apiserver/plane/db/migrations/0047_auto_20231030_0833.py @@ -0,0 +1,116 @@ +# Generated by Django 4.2.5 on 2023-10-20 12:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.api +import plane.db.models.webhook +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0046_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('url', models.URLField(validators=[plane.db.models.webhook.validate_schema, plane.db.models.webhook.validate_domain])), + ('is_active', models.BooleanField(default=True)), + ('secret_key', models.CharField(default=plane.db.models.webhook.generate_token, max_length=255)), + ('project', models.BooleanField(default=False)), + ('issue', models.BooleanField(default=False)), + ('module', models.BooleanField(default=False)), + ('cycle', models.BooleanField(default=False)), + ('issue_comment', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_webhooks', to='db.workspace')), + ], + options={ + 'verbose_name': 'Webhook', + 'verbose_name_plural': 'Webhooks', + 'db_table': 'webhooks', + 'ordering': ('-created_at',), + 'unique_together': {('workspace', 'url')}, + }, + ), + migrations.AddField( + model_name='apitoken', + name='description', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='apitoken', + name='expired_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='apitoken', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='apitoken', + name='last_used', + field=models.DateTimeField(null=True), + ), + migrations.CreateModel( + name='WebhookLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('event_type', models.CharField(blank=True, max_length=255, null=True)), + ('request_method', models.CharField(blank=True, max_length=10, null=True)), + ('request_headers', models.TextField(blank=True, null=True)), + ('request_body', models.TextField(blank=True, null=True)), + ('response_status', models.TextField(blank=True, null=True)), + ('response_headers', models.TextField(blank=True, null=True)), + ('response_body', models.TextField(blank=True, null=True)), + ('retry_count', models.PositiveSmallIntegerField(default=0)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='db.webhook')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_logs', to='db.workspace')), + ], + options={ + 'verbose_name': 'Webhook Log', + 'verbose_name_plural': 'Webhook Logs', + 'db_table': 'webhook_logs', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='APIActivityLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('token_identifier', models.CharField(max_length=255)), + ('path', models.CharField(max_length=255)), + ('method', models.CharField(max_length=10)), + ('query_params', models.TextField(blank=True, null=True)), + ('headers', models.TextField(blank=True, null=True)), + ('body', models.TextField(blank=True, null=True)), + ('response_code', models.PositiveIntegerField()), + ('response_body', models.TextField(blank=True, null=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, max_length=512, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'API Activity Log', + 'verbose_name_plural': 'API Activity Logs', + 'db_table': 'api_activity_logs', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index d8286f8f8..37ac6dfb5 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -54,7 +54,7 @@ from .view import GlobalView, IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite -from .api_token import APIToken +from .api import APIToken, APIActivityLog from .integration import ( WorkspaceIntegration, @@ -79,3 +79,5 @@ from .analytic import AnalyticView from .notification import Notification from .exporter import ExporterHistory + +from .webhook import Webhook, WebhookLog diff --git a/apiserver/plane/db/models/api.py b/apiserver/plane/db/models/api.py new file mode 100644 index 000000000..0fa1d4aba --- /dev/null +++ b/apiserver/plane/db/models/api.py @@ -0,0 +1,80 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models +from django.conf import settings + +from .base import BaseModel + + +def generate_label_token(): + return uuid4().hex + + +def generate_token(): + return "plane_api_" + uuid4().hex + + +class APIToken(BaseModel): + # Meta information + label = models.CharField(max_length=255, default=generate_label_token) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + last_used = models.DateTimeField(null=True) + + # Token + token = models.CharField( + max_length=255, unique=True, default=generate_token, db_index=True + ) + + # User Information + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="bot_tokens", + ) + user_type = models.PositiveSmallIntegerField( + choices=((0, "Human"), (1, "Bot")), default=0 + ) + workspace = models.ForeignKey( + "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True + ) + expired_at = models.DateTimeField(blank=True, null=True) + + class Meta: + verbose_name = "API Token" + verbose_name_plural = "API Tokems" + db_table = "api_tokens" + ordering = ("-created_at",) + + def __str__(self): + return str(self.user.id) + + +class APIActivityLog(BaseModel): + token_identifier = models.CharField(max_length=255) + + # Request Info + path = models.CharField(max_length=255) + method = models.CharField(max_length=10) + query_params = models.TextField(null=True, blank=True) + headers = models.TextField(null=True, blank=True) + body = models.TextField(null=True, blank=True) + + # Response info + response_code = models.PositiveIntegerField() + response_body = models.TextField(null=True, blank=True) + + # Meta information + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=512, null=True, blank=True) + + class Meta: + verbose_name = "API Activity Log" + verbose_name_plural = "API Activity Logs" + db_table = "api_activity_logs" + ordering = ("-created_at",) + + def __str__(self): + return str(self.token_identifier) diff --git a/apiserver/plane/db/models/api_token.py b/apiserver/plane/db/models/api_token.py deleted file mode 100644 index b4009e6eb..000000000 --- a/apiserver/plane/db/models/api_token.py +++ /dev/null @@ -1,41 +0,0 @@ -# Python imports -from uuid import uuid4 - -# Django imports -from django.db import models -from django.conf import settings - -from .base import BaseModel - - -def generate_label_token(): - return uuid4().hex - - -def generate_token(): - return uuid4().hex + uuid4().hex - - -class APIToken(BaseModel): - token = models.CharField(max_length=255, unique=True, default=generate_token) - label = models.CharField(max_length=255, default=generate_label_token) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="bot_tokens", - ) - user_type = models.PositiveSmallIntegerField( - choices=((0, "Human"), (1, "Bot")), default=0 - ) - workspace = models.ForeignKey( - "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True - ) - - class Meta: - verbose_name = "API Token" - verbose_name_plural = "API Tokems" - db_table = "api_tokens" - ordering = ("-created_at",) - - def __str__(self): - return str(self.user.name) diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py new file mode 100644 index 000000000..6698ec5b0 --- /dev/null +++ b/apiserver/plane/db/models/webhook.py @@ -0,0 +1,90 @@ +# Python imports +from uuid import uuid4 +from urllib.parse import urlparse + +# Django imports +from django.db import models +from django.core.exceptions import ValidationError + +# Module imports +from plane.db.models import BaseModel + + +def generate_token(): + return "plane_wh_" + uuid4().hex + + +def validate_schema(value): + parsed_url = urlparse(value) + print(parsed_url) + if parsed_url.scheme not in ["http", "https"]: + raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") + + +def validate_domain(value): + parsed_url = urlparse(value) + domain = parsed_url.netloc + if domain in ["localhost", "127.0.0.1"]: + raise ValidationError("Local URLs are not allowed.") + + +class Webhook(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_webhooks", + ) + url = models.URLField( + validators=[ + validate_schema, + validate_domain, + ] + ) + is_active = models.BooleanField(default=True) + secret_key = models.CharField(max_length=255, default=generate_token) + project = models.BooleanField(default=False) + issue = models.BooleanField(default=False) + module = models.BooleanField(default=False) + cycle = models.BooleanField(default=False) + issue_comment = models.BooleanField(default=False) + + def __str__(self): + return f"{self.workspace.slug} {self.url}" + + class Meta: + unique_together = ["workspace", "url"] + verbose_name = "Webhook" + verbose_name_plural = "Webhooks" + db_table = "webhooks" + ordering = ("-created_at",) + + +class WebhookLog(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" + ) + # Associated webhook + webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs") + + # Basic request details + event_type = models.CharField(max_length=255, blank=True, null=True) + request_method = models.CharField(max_length=10, blank=True, null=True) + request_headers = models.TextField(blank=True, null=True) + request_body = models.TextField(blank=True, null=True) + + # Response details + response_status = models.TextField(blank=True, null=True) + response_headers = models.TextField(blank=True, null=True) + response_body = models.TextField(blank=True, null=True) + + # Retry Count + retry_count = models.PositiveSmallIntegerField(default=0) + + class Meta: + verbose_name = "Webhook Log" + verbose_name_plural = "Webhook Logs" + db_table = "webhook_logs" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.event_type} {str(self.webhook.url)}" diff --git a/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py new file mode 100644 index 000000000..a1894fad5 --- /dev/null +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -0,0 +1,40 @@ +from plane.db.models import APIToken, APIActivityLog + + +class APITokenLogMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request_body = request.body + response = self.get_response(request) + self.process_request(request, response, request_body) + return response + + def process_request(self, request, response, request_body): + api_key_header = "X-Api-Key" + api_key = request.headers.get(api_key_header) + # If the API key is present, log the request + if api_key: + try: + APIActivityLog.objects.create( + token_identifier=api_key, + path=request.path, + method=request.method, + query_params=request.META.get("QUERY_STRING", ""), + headers=str(request.headers), + body=(request_body.decode('utf-8') if request_body else None), + response_body=( + response.content.decode("utf-8") if response.content else None + ), + response_code=response.status_code, + ip_address=request.META.get("REMOTE_ADDR", None), + user_agent=request.META.get("HTTP_USER_AGENT", None), + ) + + except Exception as e: + print(e) + # If the token does not exist, you can decide whether to log this as an invalid attempt + pass + + return None diff --git a/apiserver/plane/proxy/__init__.py b/apiserver/plane/proxy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/proxy/apps.py b/apiserver/plane/proxy/apps.py new file mode 100644 index 000000000..e5a5a80ef --- /dev/null +++ b/apiserver/plane/proxy/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProxyConfig(AppConfig): + name = "plane.proxy" diff --git a/apiserver/plane/proxy/rate_limit.py b/apiserver/plane/proxy/rate_limit.py new file mode 100644 index 000000000..16fce639d --- /dev/null +++ b/apiserver/plane/proxy/rate_limit.py @@ -0,0 +1,45 @@ +from django.utils import timezone +from rest_framework.throttling import SimpleRateThrottle + + +class ApiKeyRateThrottle(SimpleRateThrottle): + scope = 'api_key' + + def get_cache_key(self, request, view): + # Retrieve the API key from the request header + api_key = request.headers.get('X-Api-Key') + if not api_key: + return None # Allow the request if there's no API key + + # Use the API key as part of the cache key + return f'{self.scope}:{api_key}' + + def allow_request(self, request, view): + # Calculate the current time as a Unix timestamp + now = timezone.now().timestamp() + + # Use the parent class's method to check if the request is allowed + allowed = super().allow_request(request, view) + + if allowed: + # Calculate the remaining limit and reset time + history = self.cache.get(self.key, []) + + # Remove old histories + while history and history[-1] <= now - self.duration: + history.pop() + + # Calculate the requests + num_requests = len(history) + + # Check available requests + available = self.num_requests - num_requests + + # Unix timestamp for when the rate limit will reset + reset_time = int(now + self.duration) + + # Add headers + request.META['X-RateLimit-Remaining'] = max(0, available) + request.META['X-RateLimit-Reset'] = reset_time + + return allowed diff --git a/apiserver/plane/proxy/urls/__init__.py b/apiserver/plane/proxy/urls/__init__.py new file mode 100644 index 000000000..2ba6385d5 --- /dev/null +++ b/apiserver/plane/proxy/urls/__init__.py @@ -0,0 +1,13 @@ +from .cycle import urlpatterns as cycle_patterns +from .inbox import urlpatterns as inbox_patterns +from .issue import urlpatterns as issue_patterns +from .module import urlpatterns as module_patterns +from .project import urlpatterns as project_patterns + +urlpatterns = [ + *cycle_patterns, + *inbox_patterns, + *issue_patterns, + *module_patterns, + *project_patterns, +] diff --git a/apiserver/plane/proxy/urls/cycle.py b/apiserver/plane/proxy/urls/cycle.py new file mode 100644 index 000000000..e4f7cfe78 --- /dev/null +++ b/apiserver/plane/proxy/urls/cycle.py @@ -0,0 +1,35 @@ +from django.urls import path + +from plane.proxy.views.cycle import ( + CycleAPIEndpoint, + CycleIssueAPIEndpoint, + TransferCycleIssueAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//cycles/", + CycleAPIEndpoint.as_view(), + name="cycles", + ), + path( + "workspaces//projects//cycles//", + CycleAPIEndpoint.as_view(), + name="cycles", + ), + path( + "workspaces//projects//cycles//cycle-issues/", + CycleIssueAPIEndpoint.as_view(), + name="cycle-issues", + ), + path( + "workspaces//projects//cycles//cycle-issues//", + CycleIssueAPIEndpoint.as_view(), + name="cycle-issues", + ), + path( + "workspaces//projects//cycles//transfer-issues/", + TransferCycleIssueAPIEndpoint.as_view(), + name="transfer-issues", + ), +] diff --git a/apiserver/plane/proxy/urls/inbox.py b/apiserver/plane/proxy/urls/inbox.py new file mode 100644 index 000000000..39a630ee8 --- /dev/null +++ b/apiserver/plane/proxy/urls/inbox.py @@ -0,0 +1,17 @@ +from django.urls import path + +from plane.proxy.views import InboxIssueAPIEndpoint + + +urlpatterns = [ + path( + "workspaces//projects//inboxes//inbox-issues/", + InboxIssueAPIEndpoint.as_view(), + name="inbox-issue", + ), + path( + "workspaces//projects//inboxes//inbox-issues//", + InboxIssueAPIEndpoint.as_view(), + name="inbox-issue", + ), +] diff --git a/apiserver/plane/proxy/urls/issue.py b/apiserver/plane/proxy/urls/issue.py new file mode 100644 index 000000000..0fb236521 --- /dev/null +++ b/apiserver/plane/proxy/urls/issue.py @@ -0,0 +1,51 @@ +from django.urls import path + +from plane.proxy.views import ( + IssueAPIEndpoint, + LabelAPIEndpoint, + IssueLinkAPIEndpoint, + IssueCommentAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//issues/", + IssueAPIEndpoint.as_view(), + name="issues", + ), + path( + "workspaces//projects//issues//", + IssueAPIEndpoint.as_view(), + name="issues", + ), + path( + "workspaces//projects//issue-labels/", + LabelAPIEndpoint.as_view(), + name="labels", + ), + path( + "workspaces//projects//issue-labels//", + LabelAPIEndpoint.as_view(), + name="labels", + ), + path( + "workspaces//projects//issues//issue-links/", + IssueLinkAPIEndpoint.as_view(), + name="issue-links", + ), + path( + "workspaces//projects//issues//issue-links//", + IssueLinkAPIEndpoint.as_view(), + name="issue-links", + ), + path( + "workspaces//projects//issues//comments/", + IssueCommentAPIEndpoint.as_view(), + name="project-issue-comment", + ), + path( + "workspaces//projects//issues//comments//", + IssueCommentAPIEndpoint.as_view(), + name="project-issue-comment", + ), +] diff --git a/apiserver/plane/proxy/urls/module.py b/apiserver/plane/proxy/urls/module.py new file mode 100644 index 000000000..289c8596b --- /dev/null +++ b/apiserver/plane/proxy/urls/module.py @@ -0,0 +1,26 @@ +from django.urls import path + +from plane.proxy.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint + +urlpatterns = [ + path( + "workspaces//projects//modules/", + ModuleAPIEndpoint.as_view(), + name="modules", + ), + path( + "workspaces//projects//modules//", + ModuleAPIEndpoint.as_view(), + name="modules", + ), + path( + "workspaces//projects//modules//module-issues/", + ModuleIssueAPIEndpoint.as_view(), + name="module-issues", + ), + path( + "workspaces//projects//modules//module-issues//", + ModuleIssueAPIEndpoint.as_view(), + name="module-issues", + ), +] diff --git a/apiserver/plane/proxy/urls/project.py b/apiserver/plane/proxy/urls/project.py new file mode 100644 index 000000000..c97625197 --- /dev/null +++ b/apiserver/plane/proxy/urls/project.py @@ -0,0 +1,16 @@ +from django.urls import path + +from plane.proxy.views import ProjectAPIEndpoint + +urlpatterns = [ + path( + "workspaces//projects/", + ProjectAPIEndpoint.as_view(), + name="project", + ), + path( + "workspaces//projects//", + ProjectAPIEndpoint.as_view(), + name="project", + ), +] diff --git a/apiserver/plane/proxy/views/__init__.py b/apiserver/plane/proxy/views/__init__.py new file mode 100644 index 000000000..fcbd5182b --- /dev/null +++ b/apiserver/plane/proxy/views/__init__.py @@ -0,0 +1,18 @@ +from .project import ProjectAPIEndpoint + +from .issue import ( + IssueAPIEndpoint, + LabelAPIEndpoint, + IssueLinkAPIEndpoint, + IssueCommentAPIEndpoint, +) + +from .cycle import ( + CycleAPIEndpoint, + CycleIssueAPIEndpoint, + TransferCycleIssueAPIEndpoint, +) + +from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint + +from .inbox import InboxIssueAPIEndpoint \ No newline at end of file diff --git a/apiserver/plane/proxy/views/base.py b/apiserver/plane/proxy/views/base.py new file mode 100644 index 000000000..d5dc9fc4c --- /dev/null +++ b/apiserver/plane/proxy/views/base.py @@ -0,0 +1,101 @@ +# Python imports +import re +import json +import requests + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.tokens import RefreshToken + +# Module imports +from plane.authentication.api_authentication import APIKeyAuthentication +from plane.proxy.rate_limit import ApiKeyRateThrottle + + +class BaseAPIView(APIView): + authentication_classes = [ + APIKeyAuthentication, + ] + + permission_classes = [ + IsAuthenticated, + ] + + throttle_classes = [ + ApiKeyRateThrottle, + ] + + def _get_jwt_token(self, request): + refresh = RefreshToken.for_user(request.user) + return str(refresh.access_token) + + def _get_url_path(self, request): + match = re.search(r"/v1/(.*)", request.path) + return match.group(1) if match else "" + + def _get_headers(self, request): + return { + "Authorization": f"Bearer {self._get_jwt_token(request=request)}", + "Content-Type": request.headers.get("Content-Type", "application/json"), + } + + def _get_url(self, request): + path = self._get_url_path(request=request) + url = request.build_absolute_uri("/api/" + path) + return url + + def _get_query_params(self, request): + query_params = request.GET + return query_params + + def _get_payload(self, request): + content_type = request.headers.get("Content-Type", "application/json") + if content_type.startswith("multipart/form-data"): + files_dict = {k: v[0] for k, v in request.FILES.lists()} + return (None, files_dict) + else: + return (json.dumps(request.data), None) + + def _make_request(self, request, method="GET"): + data_payload, files_payload = self._get_payload(request=request) + response = requests.request( + method=method, + url=self._get_url(request=request), + headers=self._get_headers(request=request), + params=self._get_query_params(request=request), + data=data_payload, + files=files_payload, + ) + return response.json(), response.status_code + + def finalize_response(self, request, response, *args, **kwargs): + # Call super to get the default response + response = super().finalize_response(request, response, *args, **kwargs) + + # Add custom headers if they exist in the request META + ratelimit_remaining = request.META.get('X-RateLimit-Remaining') + if ratelimit_remaining is not None: + response['X-RateLimit-Remaining'] = ratelimit_remaining + + ratelimit_reset = request.META.get('X-RateLimit-Reset') + if ratelimit_reset is not None: + response['X-RateLimit-Reset'] = ratelimit_reset + + return response + + def get(self, request, *args, **kwargs): + response, status_code = self._make_request(request=request, method="GET") + return Response(response, status=status_code) + + def post(self, request, *args, **kwargs): + response, status_code = self._make_request(request=request, method="POST") + return Response(response, status=status_code) + + def partial_update(self, request, *args, **kwargs): + response, status_code = self._make_request(request=request, method="PATCH") + return Response(response, status=status_code) diff --git a/apiserver/plane/proxy/views/cycle.py b/apiserver/plane/proxy/views/cycle.py new file mode 100644 index 000000000..2407693af --- /dev/null +++ b/apiserver/plane/proxy/views/cycle.py @@ -0,0 +1,30 @@ +from .base import BaseAPIView + + +class CycleAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to cycle. + + """ + + pass + + +class CycleIssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to cycle issues. + + """ + + pass + + +class TransferCycleIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `create` actions for transfering the issues into a particular cycle. + + """ + + pass diff --git a/apiserver/plane/proxy/views/inbox.py b/apiserver/plane/proxy/views/inbox.py new file mode 100644 index 000000000..7e79f4c0b --- /dev/null +++ b/apiserver/plane/proxy/views/inbox.py @@ -0,0 +1,10 @@ +from .base import BaseAPIView + + +class InboxIssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to inbox issues. + + """ + pass \ No newline at end of file diff --git a/apiserver/plane/proxy/views/issue.py b/apiserver/plane/proxy/views/issue.py new file mode 100644 index 000000000..56dc71a3a --- /dev/null +++ b/apiserver/plane/proxy/views/issue.py @@ -0,0 +1,37 @@ +from .base import BaseAPIView + + +class IssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to issue. + + """ + pass + + +class LabelAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to the labels. + + """ + pass + + +class IssueLinkAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to the links of the particular issue. + + """ + pass + + +class IssueCommentAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to comments of the particular issue. + + """ + pass \ No newline at end of file diff --git a/apiserver/plane/proxy/views/module.py b/apiserver/plane/proxy/views/module.py new file mode 100644 index 000000000..3726d4af5 --- /dev/null +++ b/apiserver/plane/proxy/views/module.py @@ -0,0 +1,20 @@ +from .base import BaseAPIView + + +class ModuleAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to module. + + """ + + pass + + +class ModuleIssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to module issues. + + """ + pass diff --git a/apiserver/plane/proxy/views/project.py b/apiserver/plane/proxy/views/project.py new file mode 100644 index 000000000..6eb43d941 --- /dev/null +++ b/apiserver/plane/proxy/views/project.py @@ -0,0 +1,5 @@ +from .base import BaseAPIView + + +class ProjectAPIEndpoint(BaseAPIView): + pass \ No newline at end of file diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index dee424c44..4cb29468d 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -26,6 +26,13 @@ DEBUG = False # Allowed Hosts ALLOWED_HOSTS = ["*"] + +# To access webhook +ENABLE_WEBHOOK = os.environ.get("ENABLE_WEBHOOK", "1") == "1" + +# To access plane api through api tokens +ENABLE_API = os.environ.get("ENABLE_API", "1") == "1" + # Redirect if / is not present APPEND_SLASH = True @@ -42,6 +49,7 @@ INSTALLED_APPS = [ "plane.utils", "plane.web", "plane.middleware", + "plane.proxy", # Third-party things "rest_framework", "rest_framework.authtoken", @@ -63,6 +71,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", + "plane.middleware.api_log_middleware.APITokenLogMiddleware", ] # Rest Framework settings @@ -73,6 +82,10 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_THROTTLE_CLASSES": ("plane.proxy.rate_limit.ApiKeyRateThrottle",), + "DEFAULT_THROTTLE_RATES": { + "api_key": "60/minute", + }, } # Django Auth Backend @@ -284,7 +297,6 @@ CELERY_IMPORTS = ( "plane.bgtasks.exporter_expired_task", ) - # Sentry Settings # Enable Sentry Settings if bool(os.environ.get("SENTRY_DSN", False)): @@ -330,3 +342,4 @@ SCOUT_NAME = "Plane" # Set the variable true if running in docker environment DOCKERIZED = int(os.environ.get("DOCKERIZED", 1)) == 1 USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 + diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index 90643749c..aabc6a75a 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -14,6 +14,8 @@ urlpatterns = [ path("", include("plane.web.urls")), ] +if settings.ENABLE_API: + urlpatterns += path("api/v1/", include("plane.proxy.urls")), if settings.DEBUG: import debug_toolbar diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index e334a97a2..03e136ba1 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -10,6 +10,10 @@ x-app-env : &app-env - SENTRY_DSN=${SENTRY_DSN:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - DOCKERIZED=${DOCKERIZED:-1} + # BASE WEBHOOK + - ENABLE_WEBHOOK=${ENABLE_WEBHOOK:-1} + # BASE API + - ENABLE_API=${ENABLE_API:-1} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} @@ -56,6 +60,8 @@ x-app-env : &app-env - BUCKET_NAME=${BUCKET_NAME:-uploads} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + + services: web: <<: *app-env diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index abbb84a52..7581dfdc1 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -14,6 +14,11 @@ GITHUB_CLIENT_SECRET="" DOCKERIZED=1 CORS_ALLOWED_ORIGINS="http://localhost" +# Webhook +ENABLE_WEBHOOK=1 +# API +ENABLE_API=1 + #DB SETTINGS PGHOST=plane-db PGDATABASE=plane diff --git a/web/components/api-token/ApiTokenForm/ApiTokenDescription.tsx b/web/components/api-token/ApiTokenForm/ApiTokenDescription.tsx new file mode 100644 index 000000000..d17e4662e --- /dev/null +++ b/web/components/api-token/ApiTokenForm/ApiTokenDescription.tsx @@ -0,0 +1,55 @@ +import { TextArea } from "@plane/ui"; +import { Control, Controller, FieldErrors } from "react-hook-form"; +import { IApiToken } from "types/api_token"; +import { IApiFormFields } from "./types"; +import { Dispatch, SetStateAction } from "react"; + +interface IApiTokenDescription { + generatedToken: IApiToken | null | undefined; + control: Control; + focusDescription: boolean; + setFocusTitle: Dispatch>; + setFocusDescription: Dispatch>; +} + +export const ApiTokenDescription = ({ + generatedToken, + control, + focusDescription, + setFocusTitle, + setFocusDescription, +}: IApiTokenDescription) => ( + + focusDescription ? ( +