Compare commits

..

4 Commits

Author SHA1 Message Date
Aaron Reisman b4eab66e3d refactor: revert unintentional layout changes 2025-05-28 20:21:03 -07:00
Aaron Reisman d9d39199ae refactor: standardize loading spinner implementation in dynamic graph components
- Replaced inline loading divs with a shared LoadingSpinner component across all dynamic graph imports.
- Ensured consistent loading behavior for BarGraph, PieGraph, LineGraph, CalendarGraph, and ScatterPlotGraph components.
2025-05-28 20:14:25 -07:00
Aaron Reisman f30e31e294 refactor: enhance webpack configuration for client-side optimizations
- Updated webpack settings to improve tree shaking and chunk splitting strategies for client-side production builds.
- Increased maximum chunk size to reduce fragmentation and improve loading performance.
- Adjusted cache groups for better management of framework and library chunks.
2025-05-28 20:11:31 -07:00
Aaron Reisman dc57098507 chore: update dependencies and optimize dynamic imports in layout components
- Updated various dependencies in package.json and yarn.lock.
- Refactored layout components to dynamically import heavy components for improved performance.
- Enhanced webpack configuration for better chunk splitting and optimization.
2025-05-28 20:02:40 -07:00
1224 changed files with 19459 additions and 20657 deletions
+1 -2
View File
@@ -2,7 +2,6 @@
*.pyc
.env
venv
.venv
node_modules/
**/node_modules/
npm-debug.log
@@ -15,4 +14,4 @@ build/
out/
**/out/
dist/
**/dist/
**/dist/
-1
View File
@@ -290,6 +290,5 @@ jobs:
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
+3 -3
View File
@@ -69,14 +69,14 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up
```
4. Start web apps:
5. Start web apps:
```bash
yarn dev
```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
6. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
7. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
+6 -4
View File
@@ -67,8 +67,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -79,9 +80,10 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.Item
<Breadcrumbs.BreadcrumbItem
key={item.title}
component={<BreadcrumbLink href={item.href} label={item.title} />}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
@@ -1,11 +1,11 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAdminAuthErrorInfo } from "@plane/constants";
import { TAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+2 -2
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
@@ -89,7 +89,7 @@ const errorCodeMessages: {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -31,7 +31,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.30",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -50,6 +50,6 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -7
View File
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
@@ -692,9 +692,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get("created_by", request.user.id)
@@ -722,9 +719,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
+2 -5
View File
@@ -148,13 +148,10 @@ class ProjectMemberAdminSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
fields = ("id", "role", "member", "project")
class ProjectMemberInviteSerializer(BaseSerializer):
-16
View File
@@ -3,22 +3,11 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import contains_url
from .base import BaseSerializer
class UserSerializer(BaseSerializer):
def validate_first_name(self, value):
if contains_url(value):
raise serializers.ValidationError("First name cannot contain a URL.")
return value
def validate_last_name(self, value):
if contains_url(value):
raise serializers.ValidationError("Last name cannot contain a URL.")
return value
class Meta:
model = User
# Exclude password field from the serializer
@@ -110,16 +99,11 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
+3 -21
View File
@@ -1,5 +1,7 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -23,12 +25,10 @@ from plane.db.models import (
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
import re
class WorkSpaceSerializer(DynamicBaseSerializer):
@@ -36,21 +36,10 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True)
def validate_name(self, value):
# Check if the name contains a URL
if contains_url(value):
raise serializers.ValidationError("Name must not contain URLs")
return value
def validate_slug(self, value):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
# Slug should only contain alphanumeric characters, hyphens, and underscores
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
raise serializers.ValidationError(
"Slug can only contain letters, numbers, hyphens (-), and underscores (_)"
)
return value
class Meta:
@@ -196,7 +185,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -214,14 +202,8 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
return project.identifier if project else None
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"users/api-tokens/",
"workspaces/<str:slug>/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"users/api-tokens/<uuid:pk>/",
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens-details",
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
-19
View File
@@ -12,9 +12,6 @@ from plane.app.views import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -84,21 +81,5 @@ urlpatterns = [
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
ProjectBulkAssetEndpoint.as_view(),
name="bulk-asset-update",
),
path(
"assets/v2/workspaces/<str:slug>/check/<uuid:asset_id>/",
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
name="workspace-asset-download",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/download/<uuid:asset_id>/",
ProjectAssetDownloadEndpoint.as_view(),
name="project-asset-download",
),
]
-3
View File
@@ -106,9 +106,6 @@ from .asset.v2 import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
+19 -11
View File
@@ -1,10 +1,8 @@
# Python import
from uuid import uuid4
from typing import Optional
# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status
# Module import
@@ -15,9 +13,12 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
def post(self, request: Request) -> Response:
permission_classes = [WorkspaceEntityPermission]
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
@@ -27,6 +28,7 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -35,23 +37,29 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request: Request, pk: Optional[str] = None) -> Response:
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
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: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
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()
@@ -62,7 +70,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request: Request, slug: str) -> Response:
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
-64
View File
@@ -707,67 +707,3 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
pass
return Response(status=status.HTTP_204_NO_CONTENT)
class AssetCheckEndpoint(BaseAPIView):
"""Endpoint to check if an asset exists."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
asset = FileAsset.all_objects.filter(
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
class ProjectAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
+1 -26
View File
@@ -944,33 +944,9 @@ class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features
# if it is true then show all the issues else show only the issues created by the user
project_member_subquery = ProjectMember.objects.filter(
project_id=OuterRef("project_id"),
member=self.request.user,
is_active=True,
).filter(
Q(role__gt=ROLE.GUEST.value)
| Q(
role=ROLE.GUEST.value, project__guest_view_all_features=True
)
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(
Q(Exists(project_member_subquery))
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
@@ -1038,7 +1014,6 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
-16
View File
@@ -15,7 +15,6 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.utils.host import base_host
@@ -45,9 +44,6 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@@ -59,10 +55,6 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -74,14 +66,9 @@ class IssueLinkViewSet(BaseViewSet):
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -93,9 +80,6 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+2 -8
View File
@@ -341,10 +341,7 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
@@ -353,10 +350,7 @@ class ProjectViewSet(BaseViewSet):
)
except serializers.ValidationError:
return Response(
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
)
+1 -7
View File
@@ -168,8 +168,6 @@ class ProjectMemberViewSet(BaseViewSet):
workspace__slug=slug,
member__is_bot=False,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
@@ -315,11 +313,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
workspace__slug=slug, member_id=request.user.id, is_active=True
).values("project_id", "role")
project_members = {
+2 -19
View File
@@ -3,7 +3,6 @@ import csv
import io
import os
from datetime import date
import uuid
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
@@ -36,7 +35,6 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -45,7 +43,6 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.workspace_seed_task import workspace_seed
from plane.utils.url import contains_url
class WorkSpaceViewSet(BaseViewSet):
@@ -112,12 +109,6 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if contains_url(name):
return Response(
{"error": "Name cannot contain a URL"},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
@@ -159,18 +150,8 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
return super().destroy(request, *args, **kwargs)
@@ -178,6 +159,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = (
@@ -1,6 +1,5 @@
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party modules
@@ -134,7 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
workspace_member.is_active = False
workspace_member.save()
@@ -195,7 +194,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
# # Deactivate the user
workspace_member.is_active = False
@@ -284,7 +284,6 @@ def send_email_notification(
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
@@ -1,177 +0,0 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
Args:
url (str): The URL to crawl
Returns:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
soup = None
title = None
try:
response = requests.get(url, headers=headers, timeout=1)
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Prepare result
result = {
"title": title,
"favicon": favicon_base64["favicon_base64"],
"url": url,
"favicon_url": favicon_base64["favicon_url"],
}
return result
except Exception as e:
log_exception(e)
return {
"error": f"Unexpected error: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
Args:
soup: BeautifulSoup object
base_url: Base URL for resolving relative paths
Returns:
str: Absolute URL to favicon or None
"""
if soup is not None:
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
log_exception(e)
return None
return None
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
) -> Dict[str, Optional[str]]:
"""
Fetch favicon and encode it as base64.
Args:
favicon_url: URL to the favicon
headers: Request headers
Returns:
str: Base64 encoded favicon with data URI prefix or None
"""
try:
favicon_url = find_favicon_url(soup, url)
if favicon_url is None:
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response = requests.get(favicon_url, headers=headers, timeout=1)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
# Convert to base64
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
# Return as data URI
return {
"favicon_url": favicon_url,
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
}
except Exception as e:
logger.warning(f"Failed to fetch favicon: {e}")
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
@@ -1,23 +0,0 @@
# Generated by Django 4.2.21 on 2025-06-06 12:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0096_user_is_email_valid_user_masked_at'),
]
operations = [
migrations.AddField(
model_name='project',
name='external_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='project',
name='external_source',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
-3
View File
@@ -122,9 +122,6 @@ class Project(BaseModel):
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
# external_id for imports
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
@property
def cover_image_url(self):
+3 -45
View File
@@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User",
"last_name": "User"
}
@@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"],
last_name=user_data["last_name"]
)
user.set_password(user_data["password"])
user.save()
@@ -69,52 +69,10 @@ def session_client(api_client, create_user):
return api_client
@pytest.fixture
def create_bot_user(db):
"""Create and return a bot user instance"""
from uuid import uuid4
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"bot-{unique_id}@plane.so",
username=f"bot_user_{unique_id}",
first_name="Bot",
last_name="User",
is_bot=True,
)
user.set_password("bot@123")
user.save()
return user
@pytest.fixture
def api_token_data():
"""Return sample API token data for testing"""
from django.utils import timezone
from datetime import timedelta
return {
"label": "Test API Token",
"description": "Test description for API token",
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
}
@pytest.fixture
def create_api_token_for_user(db, create_user):
"""Create and return an API token for a specific user"""
return APIToken.objects.create(
label="Test Token",
description="Test token description",
user=create_user,
user_type=0,
)
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
return live_server
@@ -1,372 +0,0 @@
import pytest
from datetime import timedelta
from uuid import uuid4
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import APIToken, User
@pytest.mark.contract
class TestApiTokenEndpoint:
"""Test cases for ApiTokenEndpoint"""
# POST /user/api-tokens/ tests
@pytest.mark.django_db
def test_create_api_token_success(
self, session_client, create_user, api_token_data
):
"""Test successful API token creation"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert response.data["label"] == api_token_data["label"]
assert response.data["description"] == api_token_data["description"]
assert response.data["user_type"] == 0 # Human user
# Verify token was created in database
token = APIToken.objects.get(pk=response.data["id"])
assert token.user == create_user
assert token.label == api_token_data["label"]
@pytest.mark.django_db
def test_create_api_token_for_bot_user(
self, session_client, create_bot_user, api_token_data
):
"""Test API token creation for bot user"""
# Arrange
session_client.force_authenticate(user=create_bot_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert response.data["user_type"] == 1 # Bot user
@pytest.mark.django_db
def test_create_api_token_minimal_data(self, session_client, create_user):
"""Test API token creation with minimal data"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, {}, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert len(response.data["label"]) == 32 # UUID hex length
assert response.data["description"] == ""
@pytest.mark.django_db
def test_create_api_token_with_expiry(self, session_client, create_user):
"""Test API token creation with expiry date"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
future_date = timezone.now() + timedelta(days=30)
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
# Act
response = session_client.post(url, data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
# Verify expiry date was set
token = APIToken.objects.get(pk=response.data["id"])
assert token.expired_at is not None
@pytest.mark.django_db
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
"""Test API token creation without authentication"""
# Arrange
url = reverse("api-tokens")
# Act
response = api_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# GET /user/api-tokens/ tests
@pytest.mark.django_db
def test_get_all_api_tokens(self, session_client, create_user):
"""Test retrieving all API tokens for user"""
# Arrange
session_client.force_authenticate(user=create_user)
# Create multiple tokens
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
# Create a service token (should be excluded)
APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2 # Only non-service tokens
assert all(token["is_service"] is False for token in response.data)
@pytest.mark.django_db
def test_get_empty_api_tokens_list(self, session_client, create_user):
"""Test retrieving API tokens when none exist"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data == []
# GET /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_get_specific_api_token(
self, session_client, create_user, create_api_token_for_user
):
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
assert response.data["label"] == create_api_token_for_user.label
assert (
"token" not in response.data
) # Token should not be visible in read serializer
@pytest.mark.django_db
def test_get_nonexistent_api_token(self, session_client, create_user):
"""Test retrieving a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_get_other_users_api_token(self, session_client, create_user, db):
"""Test retrieving another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"other-{unique_id}@plane.so"
unique_username = f"other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# DELETE /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_delete_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
@pytest.mark.django_db
def test_delete_nonexistent_api_token(self, session_client, create_user):
"""Test deleting a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_delete_other_users_api_token(self, session_client, create_user, db):
"""Test deleting another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"delete-other-{unique_id}@plane.so"
unique_username = f"delete_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=other_token.pk).exists()
@pytest.mark.django_db
def test_delete_service_api_token_forbidden(self, session_client, create_user):
"""Test deleting a service API token (should fail)"""
# Arrange
service_token = APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=service_token.pk).exists()
# PATCH /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_patch_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == update_data["description"]
# Verify database was updated
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.label == update_data["label"]
assert create_api_token_for_user.description == update_data["description"]
@pytest.mark.django_db
def test_patch_api_token_partial_update(
self, session_client, create_user, create_api_token_for_user
):
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == original_description
@pytest.mark.django_db
def test_patch_nonexistent_api_token(self, session_client, create_user):
"""Test updating a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_patch_other_users_api_token(self, session_client, create_user, db):
"""Test updating another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"patch-other-{unique_id}@plane.so"
unique_username = f"patch_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token was not updated
other_token.refresh_from_db()
assert other_token.label == "Other Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
"""Test that all endpoints require authentication"""
# Arrange
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
for url, method in endpoints:
response = getattr(api_client, method)(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -1,75 +0,0 @@
import pytest
from plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(
email="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]
-8
View File
@@ -4,14 +4,6 @@ from typing import Optional
from urllib.parse import urlparse, urlunparse
def contains_url(value: str) -> bool:
"""
Check if the value contains a URL.
"""
url_pattern = re.compile(r"https?://|www\\.")
return bool(url_pattern.search(value))
def is_valid_url(url: str) -> bool:
"""
Validates whether the given string is a well-formed URL.
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.22
Django==4.2.21
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.32.4
requests==2.32.2
@@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on {{entity_type}}</title>
<title>Updates on issue</title>
<style type="text/css" emogrify="no"> html { font-family: system-ui; } p, h1, h2, h3, h4, ol, ul { margin: 0; } h-full { height: 100%; } a:hover { color: #3358d4 !important; } </style>
<style> *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
@@ -37,7 +37,7 @@
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{summary}} <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} </span>and others. </p>
{% endif %} <!-- {% if actors_involved == 1 %} {% if data|length > 0 and comments|length == 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. </p> {% elif data|length == 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name }} </span> added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. </p> {% elif data|length > 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. </p> {% endif %} {% else %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. </p> {% endif %} --> {% for update in data %} {% if update.changes.name %} <!-- Issue title updated -->
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The {{entity_type}} title has been updated to {{ issue.name}} </p>
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The issue title has been updated to {{ issue.name}} </p>
{% endif %} <!-- Outer update Box start --> {% if data %}
<div style=" background-color: #f7f9ff; border-radius: 8px; border-style: solid; border-width: 1px; border-color: #c1d0ff; padding: 20px; margin-top: 15px; max-width: 100%; " >
<!-- Block Heading -->
@@ -224,7 +224,7 @@
{% endif %}
</div>
<a href="{{ issue_url }}" style="text-decoration: none;">
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View {{entity_type}} </div>
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View issue </div>
</a>
</div>
<!-- Footer -->
@@ -232,7 +232,7 @@
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the issue</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
+1 -26
View File
@@ -486,7 +486,7 @@ When you want to restore the previously backed-up data, follow the instructions
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://github.com/makeplane/plane/releases/latest/download/restore.sh
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
chmod +x restore.sh
```
@@ -529,31 +529,6 @@ When you want to restore the previously backed-up data, follow the instructions
---
### Restore for Commercial Air-Gapped (Docker Compose)
When you want to restore the previously backed-up data on Plane Commercial Air-Gapped version, follow the instructions below.
1. Download the restore script using the command below
```bash
curl -fsSL -o restore-airgapped.sh https://github.com/makeplane/plane/releases/latest/download/restore-airgapped.sh
chmod +x restore-airgapped.sh
```
1. Copy the backup folder and the `restore-airgapped.sh` to `Commercial Airgapped Edition` server
1. Make sure that Plane Commercial (Airgapped) is extracted and ready to get started. In case it is running, you would need to stop that.
1. Execute the command below to restore your data.
```bash
./restore-airgapped.sh <path to backup folder containing *.tar.gz files>
```
1. After restoration, you are ready to start Plane Commercial (Airgapped) will all your previously saved data.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
-144
View File
@@ -1,144 +0,0 @@
#!/bin/bash
+set -euo pipefail
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreData() {
echo ""
echo "****************************************************"
echo "We are about to restore your data from the backup files."
echo "****************************************************"
echo ""
# set the backup folder path
BACKUP_FOLDER=${1}
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
read -p "Enter the backup folder path [$BACKUP_FOLDER]: " BACKUP_FOLDER
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
fi
fi
# check if the backup folder exists
if [ ! -d "$BACKUP_FOLDER" ]; then
echo "Error: Backup folder not found at $BACKUP_FOLDER"
exit 1
fi
# check if there are any .tar.gz files in the backup folder
if ! ls "$BACKUP_FOLDER"/*.tar.gz 1> /dev/null 2>&1; then
echo "Error: Backup folder does not contain .tar.gz files"
exit 1
fi
echo ""
echo "Using backup folder: $BACKUP_FOLDER"
echo ""
# ask for current install path
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
read -p "Enter the airgapped instance install path [$AIRGAPPED_INSTALL_PATH]: " AIRGAPPED_INSTALL_PATH
if [ -z "$AIRGAPPED_INSTALL_PATH" ]; then
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
fi
# check if the airgapped instance install path exists
if [ ! -d "$AIRGAPPED_INSTALL_PATH" ]; then
echo "Error: Airgapped instance install path not found at $AIRGAPPED_INSTALL_PATH"
exit 1
fi
echo ""
echo "Using airgapped instance install path: $AIRGAPPED_INSTALL_PATH"
echo ""
# check if the docker-compose.yaml exists
if [ ! -f "$AIRGAPPED_INSTALL_PATH/docker-compose.yml" ]; then
echo "Error: docker-compose.yml not found at $AIRGAPPED_INSTALL_PATH/docker-compose.yml"
exit 1
fi
local dockerServiceStatus
if command -v jq &> /dev/null; then
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped --format=json | jq -r .[0].Status)
else
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped | grep -o "running" | head -n 1)
fi
if [[ $dockerServiceStatus == "running" ]]; then
echo "Plane Airgapped is running. Please STOP the Plane Airgapped before restoring data."
exit 1
fi
CURRENT_USER_ID=$(id -u)
CURRENT_GROUP_ID=$(id -g)
# if the data folder not exists, create it
if [ ! -d "$AIRGAPPED_INSTALL_PATH/data" ]; then
mkdir -p "$AIRGAPPED_INSTALL_PATH/data"
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data"
fi
for BACKUP_FILE in "$BACKUP_FOLDER/*.tar.gz"; do
if [ -e "$BACKUP_FILE" ]; then
# get the basefilename without the extension
BASE_FILE_NAME=$(basename "$BACKUP_FILE" ".tar.gz")
# extract the restoreFile to the airgapped instance install path
echo "Restoring $BASE_FILE_NAME"
rm -rf "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME" || true
tar -xvzf "$BACKUP_FILE" -C "$AIRGAPPED_INSTALL_PATH/data/"
if [ $? -ne 0 ]; then
echo "Error: Failed to extract $BACKUP_FILE"
exit 1
fi
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
if [ $? -ne 0 ]; then
echo "Error: Failed to change ownership of $AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
exit 1
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: $0 /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
print_header
restoreData "$@"
+23
View File
@@ -0,0 +1,23 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"module-resolver",
{
"root": ["./src"],
"alias": {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
}
}
]
]
}
+17 -17
View File
@@ -1,17 +1,15 @@
{
"name": "live",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.js",
"module": "./dist/start.mjs",
"types": "./dist/start.d.ts",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/start.js'",
"build": "tsup",
"start": "node --env-file=.env dist/start.js",
"dev": "PORT=3100 concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
@@ -23,17 +21,14 @@
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/constants": "*",
"@plane/decorators": "*",
"@plane/editor": "*",
"@plane/logger": "*",
"@plane/types": "*",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
@@ -42,22 +37,27 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"reflect-metadata": "^0.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20",
"zod": "^3.24.2"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "22.5.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
@@ -1,96 +0,0 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
export class CollaborationController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {}
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId: req.id || crypto.randomUUID(),
};
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error) => {
this.handleConnectionError(error, clientInfo, ws);
});
} catch (error) {
this.handleConnectionError(error, clientInfo, ws);
}
}
private handleConnectionError(error: unknown, clientInfo: Record<string, any>, ws: WS) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
...clientInfo,
component: "WebSocketConnection",
},
});
// Log at appropriate level based on error category
if (appError.category === ErrorCategory.OPERATIONAL) {
logger.info(`WebSocket operational error: ${appError.message}`, {
error: appError,
clientInfo,
});
} else {
logger.error(`WebSocket error: ${appError.message}`, {
error: appError,
clientInfo,
stack: appError.stack,
});
}
// Alert if error threshold is reached
if (this.metrics.errors % 10 === 0) {
logger.warn(`High WebSocket error rate detected: ${this.metrics.errors} total errors`);
}
// Try to send error to client before closing
try {
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
}
} catch (sendError) {
// Ignore send errors at this point
}
// Close with informative message if connection is still open
if (ws.readyState === ws.OPEN) {
ws.close(
1011,
appError.category === ErrorCategory.OPERATIONAL
? `Error: ${appError.message}. Reconnect with exponential backoff.`
: "Internal server error. Please retry in a few moments."
);
}
}
getErrorMetrics() {
return {
errors: this.metrics.errors,
};
}
}
@@ -1,128 +0,0 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// decorators
import { CatchErrors } from "@/lib/decorators";
// logger
import { logger } from "@plane/logger";
import { Controller, Post } from "@plane/decorators";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
private metrics = {
conversions: 0,
errors: 0,
};
@Post("/")
@CatchErrors()
async convertDocument(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId,
};
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Log validated data
logger.info("Validated document conversion request", {
...clientInfo,
variant,
contentLength: description_html.length,
});
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Update metrics
this.metrics.conversions++;
// Log successful conversion
logger.info("Document conversion successful", {
...clientInfo,
variant,
outputLength: description_html.length,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
// Update error metrics
this.metrics.errors++;
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: {
...clientInfo,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: clientInfo,
});
}
// Log the error
logger.error("Document conversion failed", {
error: appError,
status: appError.status,
context: appError.context,
});
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
getMetrics() {
return {
conversions: this.metrics.conversions,
errors: this.metrics.errors,
};
}
}
@@ -1,16 +0,0 @@
import { CatchErrors } from "@/lib/decorators";
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
@Controller("/health")
export class HealthController {
@Get("/")
@CatchErrors()
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
}
}
-17
View File
@@ -1,17 +0,0 @@
// Export all controllers from this barrel file
import { CollaborationController } from "./collaboration.controller";
import { LiveDocumentController } from "./live-document.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController, LiveDocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
@@ -1,143 +0,0 @@
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
import { z } from "zod";
import * as Y from "yjs";
// Server agent
import { serverAgentManager } from "@/core/agents/server-agent";
// Helpers
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { env } from "@/env";
// Types
import { HocusPocusServerContext } from "@/core/types/common";
import { manualLogger } from "@/core/helpers/logger";
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor";
// Schema for request validation
const getLiveDocumentValuesSchema = z.object({
documentId: z.string().min(1, "Document ID is required"),
variant: z.enum(["document"]),
workspaceSlug: z.string().min(1, "Workspace slug is required"),
});
@Controller("/live-document")
export class LiveDocumentController {
@Get("/")
async getLiveDocumentValues(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
try {
if (req.headers["live-server-secret-key"] !== env.LIVE_SERVER_SECRET_KEY) {
return res.status(401).json({
message: "Unauthorized access",
status: 401,
context: {
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
requestId,
},
});
}
const validatedData = getLiveDocumentValuesSchema.parse(req.query);
const { documentId, workspaceSlug } = validatedData;
const context: Partial<HocusPocusServerContext> = {
workspaceSlug,
};
try {
const { connection } = await serverAgentManager.getConnection(documentId, context);
// Define the document type
type DocumentData = {
description_binary: string;
description: object;
description_html: string;
name: string;
};
// Create a promise to wrap the setTimeout
const loadDocumentWithDelay = new Promise<DocumentData | null>((resolve) => {
let documentData: DocumentData;
connection.transact((doc) => {
const type = doc.getXmlFragment("default");
const contentDoc = type.doc;
if (!contentDoc) {
resolve(null);
return;
}
const yjsBinary = Y.encodeStateAsUpdate(contentDoc);
const { contentBinaryEncoded, contentJSON, contentHTML, titleHTML } =
getAllDocumentFormatsFromDocumentEditorBinaryData(yjsBinary);
documentData = {
description_binary: contentBinaryEncoded,
description: contentJSON,
description_html: contentHTML,
name: titleHTML,
};
resolve(documentData);
});
});
// Await the delayed document loading
const documentLoaded = await loadDocumentWithDelay;
await serverAgentManager.releaseConnection(documentId);
// Return the converted document
res.status(200).json(documentLoaded);
} catch (error) {
// Error during server agent connection or conversion
manualLogger.error(`Error processing document ${documentId}:`, error);
res.status(400).json({
loaded: false,
message: "Document not currently loaded in memory",
});
}
} catch (error) {
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: {
requestId,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "get-live-document-values-controller",
operation: "getLiveDocumentValues",
extraContext: { requestId },
});
}
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
}
-1
View File
@@ -1 +0,0 @@
export * from "./register";
@@ -1,58 +0,0 @@
import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
const pageService = new PageService();
/**
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
/**
* Updates the description of a project page
*/
export const updatePageDescription = async (
params: URLSearchParams | undefined,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
@@ -1,3 +0,0 @@
export * from "./handlers"
export * from "./transformers";
export * from "./project-page-handler";
@@ -1,41 +0,0 @@
import {
DocumentHandler,
DocumentFetchParams,
DocumentStoreParams,
HandlerDefinition,
} from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import { fetchPageDescriptionBinary, updatePageDescription } from "./handlers";
/**
* Handler for "project_page" document type
*/
export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
return await fetchPageDescriptionBinary(params, pageId, cookie);
},
/**
* Store project page description
*/
store: async ({ pageId, state, params, context }: DocumentStoreParams) => {
const { cookie } = context;
await updatePageDescription(params, pageId, state, cookie);
},
};
// Define the project page handler definition
export const projectPageHandlerDefinition: HandlerDefinition = {
selector: (context) => context.documentType === "project_page",
handler: projectPageHandler,
priority: 10, // Standard priority
};
// Register the handler directly from CE
export function registerProjectPageHandler() {
handlerFactory.register(projectPageHandlerDefinition);
}
@@ -1,27 +0,0 @@
import { PageService } from "@/core/services/page.service";
import { getBinaryDataFromHTMLString } from "@/core/helpers/page";
import { logger } from "@plane/logger";
const pageService = new PageService();
/**
* Transforms HTML description to binary format
*/
export const transformHTMLToBinary = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
-5
View File
@@ -1,5 +0,0 @@
import { registerProjectPageHandler } from "./project-page/project-page-handler";
export function initializeDocumentHandlers() {
registerProjectPageHandler();
}
+14
View File
@@ -0,0 +1,14 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
+15
View File
@@ -0,0 +1,15 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
updatedDescription: Uint8Array;
}
export const updateDocument = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Update failed: Invalid document type ${documentType} provided.`);
}
+1 -28
View File
@@ -1,28 +1 @@
import { TPage } from "@plane/types";
import * as Y from "yjs";
export type TAdditionalDocumentTypes = null;
export interface ActionCondition {
name: string;
check: (page: TPage, isInDocument: boolean, context?: any) => boolean;
}
export interface DocumentAction {
name: string;
execute: (
xmlFragment: Y.XmlFragment,
page: TPage,
context: {
childNodesMap?: Map<string, Y.XmlElement>;
embeddedIDs?: Set<string>;
[key: string]: any;
}
) => void;
}
export interface ActionRule {
condition: string;
action: string;
priority: number;
}
export type TAdditionalDocumentTypes = {};
-66
View File
@@ -1,66 +0,0 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
terminationTimeout: env.SHUTDOWN_TIMEOUT,
};
-439
View File
@@ -1,439 +0,0 @@
import * as Y from "yjs";
import { Response } from "express";
import { DirectConnection, Hocuspocus } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { manualLogger } from "@/core/helpers/logger";
import { TPage } from "@plane/types";
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentProcessor } from "@/plane-live/lib/document-processor";
import { getDocumentHandler } from "@/core/handlers/page-handlers";
/**
* Metadata for a stored connection
*/
interface ConnectionData {
connection: DirectConnection;
context: Partial<HocusPocusServerContext>;
createdAt: number;
lastUsed: number;
}
/**
* Connection statistics for a document
*/
interface ConnectionStats {
documentId: string;
createdAt: string;
lastUsed: string;
idleTime: string;
}
/**
* Overall statistics for the connection manager
*/
interface ManagerStats {
totalConnections: number;
connections: ConnectionStats[];
}
/**
* Error thrown when the ServerAgentManager is not properly initialized
*/
class ServerAgentManagerError extends Error {
constructor(message: string) {
super(message);
this.name = "ServerAgentManagerError";
}
}
/**
* Manages server-side connections (agents) to the Hocuspocus server
* Implements the Singleton pattern to ensure only one instance exists
*/
export class ServerAgentManager {
private static instance: ServerAgentManager;
private connections: Map<string, ConnectionData>;
public hocuspocusServer: Hocuspocus | null;
private cleanupInterval: ReturnType<typeof setTimeout> | null = null;
/**
* Private constructor to enforce singleton pattern
*/
private constructor() {
this.connections = new Map<string, ConnectionData>();
this.hocuspocusServer = null;
this.cleanupInterval = null;
}
/**
* Get the singleton instance of the ServerAgentManager
* @returns {ServerAgentManager} The singleton instance
*/
public static getInstance(): ServerAgentManager {
if (!ServerAgentManager.instance) {
ServerAgentManager.instance = new ServerAgentManager();
}
return ServerAgentManager.instance;
}
/**
* Initialize the manager with a Hocuspocus server instance
* @param {Hocuspocus} server - The Hocuspocus server instance
* @returns {ServerAgentManager} The initialized manager instance for chaining
*/
public initialize(server: Hocuspocus): ServerAgentManager {
this.hocuspocusServer = server;
// Set up periodic cleanup of unused connections
this.startConnectionCleanup();
manualLogger.info("ServerAgentManager initialized");
return this;
}
/**
* Get or create a connection to a document
* @param {string} documentId - The document ID to connect to
* @param {HocusPocusServerContext} context - Additional context for the connection
* @returns {Promise<ConnectionData>} - The connection data object
* @throws {ServerAgentManagerError} If the manager is not initialized
*/
public async getConnection(documentId: string, context: Partial<HocusPocusServerContext>): Promise<ConnectionData> {
if (!this.hocuspocusServer) {
throw new ServerAgentManagerError("ServerAgentManager not initialized with a Hocuspocus server");
}
// Check if we already have a connection for this document
if (this.connections.has(documentId)) {
// manualLogger.info(`Reusing existing connection for document: ${documentId}`);
const connectionData = this.connections.get(documentId)!;
// Update last used timestamp
connectionData.lastUsed = Date.now();
return connectionData;
}
context.documentType = context.documentType === "sync_agent" ? "sync_agent" : "server_agent";
try {
// Create a new connection
const connection = await this.hocuspocusServer.openDirectConnection(documentId, {
documentType: context.documentType,
projectId: context.projectId,
workspaceSlug: context.workspaceSlug,
// triggerExecutionAfterLoad: context.triggerExecutionAfterLoad,
agentId: uuidv4(), // Unique ID for this server agent
});
// Store the connection with metadata
const connectionData: ConnectionData = {
connection,
context,
createdAt: Date.now(),
lastUsed: Date.now(),
};
this.connections.set(documentId, connectionData);
return connectionData;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Failed to create connection for document ${documentId}: ${errorMessage}`);
throw new ServerAgentManagerError(`Failed to create connection: ${errorMessage}`);
}
}
/**
* Execute a transaction on a document
* @param {string} documentId - The document ID
* @param {(doc: Y.Doc) => void | Promise<void>} transactionFn - The transaction function
* @param {HocusPocusServerContext} context - Additional context for the connection
* @returns {Promise<void>} - Promise that resolves when the transaction is complete
* @throws {ServerAgentManagerError} If the transaction fails
*/
public async executeTransaction(
documentId: string,
transactionFn: (_doc: Y.Doc) => void | Promise<void>,
context: Partial<HocusPocusServerContext>,
res?: Response
): Promise<boolean> {
let connectionData: ConnectionData | null = null;
try {
connectionData = await this.getConnection(documentId, context);
connectionData.lastUsed = Date.now();
connectionData.context = { ...connectionData.context };
// Execute the transaction
await connectionData.connection.transact(transactionFn);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Transaction error for document ${documentId}: ${errorMessage}`, {
documentId,
error: error instanceof Error ? error.stack : errorMessage,
context,
});
// Notify about transaction failure if needed
this.notifyTransactionFailure(documentId, errorMessage, res);
return false;
}
}
public notifySyncTrigger(
pageId: string,
context: HocusPocusServerContext,
options: {
componentType?: string;
targetNodeId?: string;
[key: string]: any;
} = {}
) {
if (!this.hocuspocusServer) return;
this.executeTransaction(
pageId,
async (doc) => {
const xmlFragment = doc.getXmlFragment("default");
let subPagesFromBackend: TPage[] | undefined = [];
if (!context.documentType) {
return;
}
const documentHandler = getDocumentHandler(context.documentType);
if (documentHandler && documentHandler.fetchSubPages) {
subPagesFromBackend = await documentHandler.fetchSubPages({
context,
pageId,
});
}
// // Process the document using our extensible system
DocumentProcessor.process(xmlFragment, subPagesFromBackend || [], options);
},
{
...context,
documentType: "sync_agent",
}
);
}
/**
* Notify clients about a successful transaction
* @private
* @param {string} documentId - The document ID
*/
public notifyTransactionSuccess(documentId: string, res?: Response): void {
if (!this.hocuspocusServer) return;
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return;
try {
manualLogger.info(`notified transaction success for ${documentId}:`);
// res.status(200).json({ success: true });
} catch (error) {
manualLogger.error(`Error notifying transaction success for ${documentId}:`, error);
}
}
/**
* Notify clients about a failed transaction
* @private
* @param {string} documentId - The document ID
* @param {string} errorMessage - The error message
*/
private notifyTransactionFailure(documentId: string, errorMessage: string, res?: Response): void {
if (!this.hocuspocusServer) return;
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return;
try {
// res.status(200).json({ success: true });
// document.broadcastStateless(
// JSON.stringify({
// type: "transaction_status",
// status: "error",
// message: errorMessage,
// timestamp: new Date().toISOString(),
// })
// );
} catch (error) {
manualLogger.error(`Error notifying transaction failure for ${documentId}:`, error);
}
}
/**
* Release a connection when it's no longer needed
* @param {string} documentId - The document ID
* @returns {Promise<void>}
*/
public async releaseConnection(documentId: string): Promise<void> {
if (!this.connections.has(documentId)) {
return;
}
const connectionData = this.connections.get(documentId)!;
try {
connectionData.connection.disconnect();
this.connections.delete(documentId);
manualLogger.info(`Released connection for document: ${documentId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
manualLogger.error(`Error releasing connection for document ${documentId}: ${errorMessage}`);
// We still want to remove it from our map even if disconnect fails
this.connections.delete(documentId);
}
}
/**
* Check if a document has any client connections (excluding our agent)
* @param {string} documentId - The document ID
* @returns {boolean} - True if there are client connections
*/
/**
* Check if a document has any client connections (excluding our agent)
* @param {string} documentId - The document ID
* @returns {boolean} - True if there are client connections
*/
public hasClientConnections(documentId: string): boolean {
if (!this.hocuspocusServer) return false;
// Get the document from the server
const document = this.hocuspocusServer.documents.get(documentId);
if (!document) return false;
return document.connections.size > 0;
}
/**
* Start periodic cleanup of unused connections
* @private
*/
private startConnectionCleanup(): void {
// Check every 5 minutes for unused connections
const CLEANUP_INTERVAL = 5 * 60 * 1000;
// Clear any existing interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.cleanupInterval = setInterval(() => {
this.cleanupUnusedConnections().catch((error) => {
manualLogger.error("Error during connection cleanup:", error);
});
}, CLEANUP_INTERVAL);
}
/**
* Clean up connections that are no longer needed
* @private
*/
private async cleanupUnusedConnections(): Promise<void> {
const documentsToCleanup: string[] = [];
for (const [documentId] of this.connections.entries()) {
// If no client connections exist (only our agent remains), schedule cleanup
if (!this.hasClientConnections(documentId)) {
documentsToCleanup.push(documentId);
}
}
// Release connections outside the loop to avoid modifying the map during iteration
for (const documentId of documentsToCleanup) {
manualLogger.info(`Cleaning up connection for document with no clients: ${documentId}`);
await this.releaseConnection(documentId);
}
if (documentsToCleanup.length > 0) {
manualLogger.info(`Cleaned up ${documentsToCleanup.length} connections with no clients`);
}
}
/**
* Check if a document has no client connections and release the agent if needed
* @param {string} documentId - The document ID to check
* @returns {Promise<boolean>} - True if the connection was released
*/
public async checkAndReleaseIfNoClients(documentId: string): Promise<boolean> {
if (!this.connections.has(documentId)) {
return false;
}
if (!this.hasClientConnections(documentId)) {
manualLogger.info(`No clients left for document ${documentId}, releasing agent connection`);
await this.releaseConnection(documentId);
return true;
}
return false;
}
/**
* Set up hooks on the HocusPocus server to trigger cleanup when clients disconnect
* @returns {ServerAgentManager} The manager instance for chaining
*/
public setupHocusPocusHooks(): ServerAgentManager {
if (!this.hocuspocusServer) {
throw new ServerAgentManagerError("ServerAgentManager not initialized with a Hocuspocus server");
}
// Add a hook to the onDisconnect event
this.hocuspocusServer.configure({
async onDisconnect({ documentName }) {
// Use a small delay to ensure the connection is fully closed before checking
setTimeout(async () => {
try {
await serverAgentManager.checkAndReleaseIfNoClients(documentName);
} catch (error) {
manualLogger.error(`Error checking client connections for ${documentName}:`, error);
}
}, 100); // Small delay to ensure connection state is updated
},
});
return this;
}
/**
* Get statistics about current connections
* @returns {ManagerStats} - Connection statistics
*/
public getStats(): ManagerStats {
return {
totalConnections: this.connections.size,
connections: Array.from(this.connections.entries()).map(([documentId, data]) => ({
documentId,
createdAt: new Date(data.createdAt).toISOString(),
lastUsed: new Date(data.lastUsed).toISOString(),
idleTime: Math.round((Date.now() - data.lastUsed) / 1000) + "s",
})),
};
}
/**
* Shutdown the manager and clean up all connections
* @returns {Promise<void>}
*/
public async shutdown(): Promise<void> {
// Clear the cleanup interval
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
// Release all connections
const documentIds = Array.from(this.connections.keys());
for (const documentId of documentIds) {
await this.releaseConnection(documentId);
}
manualLogger.info("ServerAgentManager shut down successfully");
}
}
// Create and export the singleton instance
export const serverAgentManager = ServerAgentManager.getInstance();
-4
View File
@@ -1,4 +0,0 @@
import { CONTROLLERS } from "@/plane-live/controllers";
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
@@ -1,233 +0,0 @@
import { HocusPocusServerContext } from "@/core/types/common";
import { BasePageService } from "@/core/services/base-page.service";
import { logger } from "@plane/logger";
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
import { TPage } from "@plane/types";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
/**
* Base class for page handlers with factory integration
*/
export abstract class BasePageHandler<TService extends BasePageService, TConfig extends Record<string, any>> {
/**
* The document type identifier
*/
protected abstract documentType: string;
constructor(protected service: TService) {}
/**
* Abstract method to get config from context
*/
protected abstract getConfig(context: HocusPocusServerContext): TConfig;
/**
* Fetches the binary description data for a page
*/
public async fetchPageDescriptionBinary({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<Uint8Array | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const response = await this.service.fetchDescriptionBinary({
pageId,
cookie,
config,
});
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await this.transformHTMLToBinary(config, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
}
/**
* Updates the description of a page
*/
public async updatePageDescription({
pageId,
state: updatedDescription,
title,
context,
}: {
pageId: string;
state: Uint8Array;
title: string;
context: HocusPocusServerContext;
}): Promise<void> {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await this.service.updateDescription({
config,
pageId,
data: payload,
cookie,
});
}
/**
* Fetches the title of a page
*/
public async fetchPageTitle({
context,
pageId,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<string | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
try {
const pageDetails = await this.service.fetchDetails({
config,
pageId,
cookie,
});
return pageDetails.name;
} catch (error) {
logger.error("Error while fetching page title", error);
throw error;
}
}
/**
* Updates the title of a page
*/
public async updatePageTitle({
context,
pageId,
title,
abortSignal,
}: {
pageId: string;
title: string;
abortSignal?: AbortSignal;
context: HocusPocusServerContext;
}): Promise<void> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
const payload = {
name: title,
};
await this.service.updateTitle({
config,
pageId,
data: payload,
cookie,
abortSignal,
});
}
/**
* Fetches sub-page details
*/
public async fetchPageSubPageDetails({
context,
pageId,
}: {
pageId: string;
context: HocusPocusServerContext;
}): Promise<TPage[] | undefined> {
const { cookie } = context;
const config = this.getConfig(context);
if (!pageId) return;
try {
const response = await this.service.fetchSubPageDetails({
config,
pageId,
cookie,
});
return response;
} catch (error) {
logger.error("Fetch error:", error);
throw error;
}
}
/**
* Transforms HTML to binary data
*/
private async transformHTMLToBinary(
config: TConfig,
pageId: string,
cookie: string
): Promise<Uint8Array | undefined> {
try {
const pageDetails = await this.service.fetchDetails({
config,
pageId,
cookie,
});
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
}
/**
* Creates the handler definition
*/
protected createHandlerDefinition(): HandlerDefinition {
const handler: DocumentHandler = {
fetch: this.fetchPageDescriptionBinary.bind(this),
store: this.updatePageDescription.bind(this),
fetchTitle: this.fetchPageTitle.bind(this),
updateTitle: this.updatePageTitle.bind(this),
fetchSubPages: this.fetchPageSubPageDetails.bind(this),
};
return {
selector: (context: Partial<HocusPocusServerContext>) => context.documentType === this.documentType,
handler,
priority: 10, // Standard priority
};
}
/**
* Registers the handler with the handler factory
*/
public register(): void {
const definition = this.createHandlerDefinition();
handlerFactory.register(definition);
}
}
@@ -1,21 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { ServerAgentService } from "@/core/services/server-agent.service";
import { HocusPocusServerContext } from "@/core/types/common";
interface ServerAgentConfig {}
const serverAgentService = new ServerAgentService();
export class ServerAgentHandler extends BasePageHandler<ServerAgentService, ServerAgentConfig> {
protected documentType = "server_agent";
constructor() {
super(serverAgentService);
}
protected getConfig(context: HocusPocusServerContext): ServerAgentConfig {
return {};
}
}
export const serverAgentHandler = new ServerAgentHandler();
@@ -1,22 +0,0 @@
import { BasePageHandler } from "@/core/document-types/base-page/handlers";
import { HocusPocusServerContext } from "@/core/types/common";
import { SyncAgentService } from "@/core/services/sync-agent.service";
interface SyncAgentConfig {}
const syncAgentService = new SyncAgentService();
export class SyncAgentHandler extends BasePageHandler<SyncAgentService, SyncAgentConfig> {
protected documentType = "sync_agent";
constructor() {
super(syncAgentService);
}
protected getConfig(context: HocusPocusServerContext): SyncAgentConfig {
return {};
}
}
export const syncAgentHandler = new SyncAgentHandler();
-128
View File
@@ -1,128 +0,0 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { storePayload } from "@hocuspocus/server";
import { extractTextFromHTML } from "./title-update/title-utils";
import { getDocumentHandler } from "../handlers/page-handlers";
export const createDatabaseExtension = () => {
return new Database({
fetch: handleFetch,
store: handleStore as (data: storePayload) => Promise<void>,
});
};
const handleFetch = async ({
context,
documentName,
}: {
context: HocusPocusServerContext;
documentName: string;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const pageId = documentName as TDocumentTypes;
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "fetch",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(documentType);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
});
if (!fetchedData) {
handleError(null, {
errorType: "not-found",
message: `Failed to fetch document: ${pageId}`,
component: "database-extension",
operation: "fetch",
extraContext: { documentType, pageId },
});
}
return fetchedData;
},
{
params: { pageId, documentType: context.documentType },
extra: { operation: "fetch" },
}
)();
return fetchedData;
};
const handleStore = async ({
context,
state,
documentName,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
documentName: string;
}) => {
const pageId = documentName;
if (!context) {
console.error("Context is undefined in handleStore for document:", pageId);
return;
}
await catchAsync(
async () => {
if (!state) {
handleError(null, {
errorType: "bad-request",
message: "Loaded binary state is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(documentType);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
title,
});
},
{
params: {
pageId,
documentType: context?.documentType || "unknown",
},
extra: { operation: "store" },
}
)();
};
+128 -13
View File
@@ -1,27 +1,142 @@
// hocuspocus extensions and core
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { setupRedisExtension } from "@/core/extensions/setup-redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
fetchPageDescriptionBinary,
updatePageDescription,
} from "@/core/lib/page.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
// types
import {
type HocusPocusServerContext,
type TDocumentTypes,
} from "@/core/types/common.js";
export const getExtensions = async (): Promise<Extension[]> => {
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
logger.info(message);
manualLogger.info(message);
},
}),
new Database({
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
});
},
store: async ({
context,
state,
documentName: pageId,
requestParameters,
}) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
});
},
}),
createDatabaseExtension(),
new TitleSyncExtension(),
];
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
if (redisExtensions) {
extensions.push(redisExtensions);
const redisUrl = getRedisUrl();
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
return extensions;
-17
View File
@@ -1,17 +0,0 @@
import { Redis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
export class CustomHocuspocusRedisExtension extends Redis {
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
return this.pub.publishBuffer(
// we're accessing the private method of the hocuspocus redis extension
this["pubKey"](documentName),
Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())])
);
}
}
-32
View File
@@ -1,32 +0,0 @@
// core helpers and utilities
import { RedisManager } from "@/core/lib/redis-manager";
import { CustomHocuspocusRedisExtension } from "./redis";
import { shutdownManager } from "../shutdown-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async () => {
const redisManager = RedisManager.getInstance();
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
return new CustomHocuspocusRedisExtension({
redis: redisClient,
});
} else {
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
}
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};
-139
View File
@@ -1,139 +0,0 @@
// hocuspocus
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
import { HocusPocusServerContext } from "@/core/types/common";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
// handlers
import { getDocumentHandler } from "@/core/handlers/page-handlers";
// helpers
import { generateTitleProsemirrorJson } from "@/core/helpers/generate-title-prosemirror-json";
import { extractTextFromHTML } from "./title-update/title-utils";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { broadcastMessageToPage } from "@/plane-live/lib/broadcast-message-to-page";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
instance!: Hocuspocus;
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const documentHandler = getDocumentHandler(context.documentType);
const title = await documentHandler.fetchTitle?.({
context,
pageId: document.name,
});
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
// editor
TITLE_EDITOR_EXTENSIONS
);
document.merge(titleField);
}
} catch (error) {
console.error("Error in onLoadDocument: ", error);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
instance,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
const documentHandler = getDocumentHandler(context.documentType);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context, documentHandler);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Set up observer for title field
const titleObserver = (events: Y.YEvent<any>[]) => {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Schedule an update with the manager
const manager = this.titleUpdateManagers.get(documentName);
// In your titleObserver
if (context.parentId) {
const event = createRealtimeEvent({
user_id: context.userId,
workspace_slug: context.workspaceSlug as string,
action: "title_updated",
page_id: documentName,
data: { title },
descendants_ids: [],
});
broadcastMessageToPage(instance, context.parentId, event);
}
if (manager) {
manager.scheduleUpdate(title);
}
};
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName }: { documentName: string }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
this.titleObservers.delete(documentName);
}
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -1,275 +0,0 @@
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: ReturnType<typeof setTimeout> | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
console.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
console.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -1,94 +0,0 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DebounceManager } from "./debounce";
import { HocusPocusServerContext } from "@/core/types/common";
import { logger } from "@plane/logger";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private context: HocusPocusServerContext;
private documentHandler: DocumentHandler;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
/**
* Create a new TitleUpdateManager instance
*/
constructor(
documentName: string,
context: HocusPocusServerContext,
documentHandler: DocumentHandler,
wait: number = 5000
) {
this.documentName = documentName;
this.context = context;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
if (!this.documentHandler.updateTitle) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await this.documentHandler.updateTitle({
context: this.context,
pageId: this.documentName,
title: title,
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
console.error(`Error updating title for ${this.documentName}:`, error);
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -1,8 +0,0 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -1,29 +0,0 @@
import { Request, Response } from "express";
import { manualLogger } from "@/core/helpers/logger";
import { TConvertDocumentRequestBody } from "@/core/types/common";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
export const handleConvertDocument = (req: Request, res: Response) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
};
-1
View File
@@ -1 +0,0 @@
export { handleConvertDocument } from "./convert-document.handler";
@@ -1,34 +0,0 @@
import { HocusPocusServerContext } from "@/core/types/common";
import { DocumentHandler, HandlerDefinition } from "@/core/types/document-handler";
/**
* Class that manages handler selection based on multiple criteria
*/
export class DocumentHandlerFactory {
private handlers: HandlerDefinition[] = [];
/**
* Register a handler with its selection criteria
*/
register(definition: HandlerDefinition): void {
this.handlers.push(definition);
// Sort handlers by priority (highest first)
this.handlers.sort((a, b) => b.priority - a.priority);
}
/**
* Get the appropriate handler based on the provided context
*/
getHandler(context: Partial<HocusPocusServerContext>): DocumentHandler {
// Find the first handler whose selector returns true
const matchingHandler = this.handlers.find((h) => h.selector(context));
// console.log("matchingHandler:", matchingHandler);
// Return the matching handler or fall back to null/undefined
// (This will cause an error if no handlers match, which is good for debugging)
return matchingHandler?.handler as DocumentHandler;
}
}
// Create the singleton instance
export const handlerFactory = new DocumentHandlerFactory();
@@ -1,26 +0,0 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/page-handlers/handler-factory";
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Get a document handler based on the provided context criteria
* @param documentType The primary document type
* @param additionalContext Optional additional context criteria
* @returns The appropriate document handler
*/
export function getDocumentHandler(
documentType: TDocumentTypes,
additionalContext?: Omit<HocusPocusServerContext, "documentType">
): DocumentHandler {
// Create a context object with all criteria
const context: Partial<HocusPocusServerContext> = {
documentType: documentType,
...additionalContext,
};
// Use the factory to get the appropriate handler
return handlerFactory.getHandler(context);
}
// Export the factory for direct access if needed
export { handlerFactory };
+44
View File
@@ -0,0 +1,44 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
+21
View File
@@ -0,0 +1,21 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
// Send the response
res.json({
error: {
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
},
});
};
@@ -1,276 +0,0 @@
import { AppError, HttpStatusCode, ErrorCategory } from "./error-handler";
/**
* Map of error types to their corresponding factory functions
* This ensures that error types and their implementations stay in sync
*/
interface ErrorFactory {
statusCode: number;
category: ErrorCategory;
defaultMessage: string;
createError: (message?: string, context?: Record<string, any>) => AppError;
}
const ERROR_FACTORIES = {
"bad-request": {
statusCode: HttpStatusCode.BAD_REQUEST,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Bad Request",
createError: (message = "Bad Request", context?) =>
new AppError(message, HttpStatusCode.BAD_REQUEST, ErrorCategory.OPERATIONAL, context),
},
unauthorized: {
statusCode: HttpStatusCode.UNAUTHORIZED,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unauthorized",
createError: (message = "Unauthorized", context?) =>
new AppError(message, HttpStatusCode.UNAUTHORIZED, ErrorCategory.OPERATIONAL, context),
},
forbidden: {
statusCode: HttpStatusCode.FORBIDDEN,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Forbidden",
createError: (message = "Forbidden", context?) =>
new AppError(message, HttpStatusCode.FORBIDDEN, ErrorCategory.OPERATIONAL, context),
},
"not-found": {
statusCode: HttpStatusCode.NOT_FOUND,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource not found",
createError: (message = "Resource not found", context?) =>
new AppError(message, HttpStatusCode.NOT_FOUND, ErrorCategory.OPERATIONAL, context),
},
conflict: {
statusCode: HttpStatusCode.CONFLICT,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource conflict",
createError: (message = "Resource conflict", context?) =>
new AppError(message, HttpStatusCode.CONFLICT, ErrorCategory.OPERATIONAL, context),
},
"unprocessable-entity": {
statusCode: HttpStatusCode.UNPROCESSABLE_ENTITY,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unprocessable Entity",
createError: (message = "Unprocessable Entity", context?) =>
new AppError(message, HttpStatusCode.UNPROCESSABLE_ENTITY, ErrorCategory.OPERATIONAL, context),
},
"too-many-requests": {
statusCode: HttpStatusCode.TOO_MANY_REQUESTS,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Too many requests",
createError: (message = "Too many requests", context?) =>
new AppError(message, HttpStatusCode.TOO_MANY_REQUESTS, ErrorCategory.OPERATIONAL, context),
},
internal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.PROGRAMMING,
defaultMessage: "Internal Server Error",
createError: (message = "Internal Server Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.PROGRAMMING, context),
},
"service-unavailable": {
statusCode: HttpStatusCode.SERVICE_UNAVAILABLE,
category: ErrorCategory.SYSTEM,
defaultMessage: "Service Unavailable",
createError: (message = "Service Unavailable", context?) =>
new AppError(message, HttpStatusCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM, context),
},
fatal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.FATAL,
defaultMessage: "Fatal Error",
createError: (message = "Fatal Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context),
},
} satisfies Record<string, ErrorFactory>;
// Create the type from the keys of the error factories map
export type ErrorType = keyof typeof ERROR_FACTORIES;
// -------------------------------------------------------------------------
// Primary public API - Recommended for most use cases
// -------------------------------------------------------------------------
/**
* Base options for handleError function
*/
type BaseErrorHandlerOptions = {
// Error classification options
errorType?: ErrorType;
message?: string;
// Context information
component: string;
operation: string;
extraContext?: Record<string, any>;
// Behavior options
rethrowIfAppError?: boolean;
};
/**
* Options for throwing variant of handleError - discriminated by throw: true
*/
export type ThrowingOptions = BaseErrorHandlerOptions & {
throw: true;
};
/**
* Options for non-throwing variant of handleError - default behavior
*/
export type NonThrowingOptions = BaseErrorHandlerOptions;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options with throw: true to throw the error instead of returning it
* @returns Never returns - always throws
* @example
* // Throwing version
* handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById',
* throw: true
* });
*/
export function handleError(error: unknown, options: ThrowingOptions): never;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options (non-throwing by default)
* @returns The AppError instance
* @example
* // Non-throwing version (default)
* const appError = handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById'
* });
* return { error: appError.output() };
*/
export function handleError(error: unknown, options: NonThrowingOptions): AppError;
/**
* Implementation of handleError that handles both throwing and non-throwing cases
*/
export function handleError(error: unknown, options: ThrowingOptions | NonThrowingOptions): AppError | never {
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
// If the error is already an AppError and we want to rethrow it as is
if (options.rethrowIfAppError !== false && error instanceof AppError) {
if (shouldThrow) {
throw error;
}
return error;
}
// Format the error message
const errorMessage = options.message
? error instanceof Error
? `${options.message}: ${error.message}`
: error
? `${options.message}: ${String(error)}`
: options.message
: error instanceof Error
? error.message
: error
? String(error)
: "Unknown error occurred";
// Build context object
const context = {
component: options.component,
operation: options.operation,
originalError: error,
...(options.extraContext || {}),
};
// Create the appropriate error type using our factory map
const errorType = options.errorType || "internal";
const factory = ERROR_FACTORIES[errorType];
if (!factory) {
// If no factory found, default to internal error
return ERROR_FACTORIES.internal.createError(errorMessage, context);
}
// Create the error with the factory
const appError = factory.createError(errorMessage, context);
// If we should throw, do so now
if (shouldThrow) {
throw appError;
}
return appError;
}
/**
* Utility function to convert errors or enhance existing AppErrors
*/
export const convertError = (
error: Error,
options?: {
statusCode?: number;
message?: string;
category?: ErrorCategory;
context?: Record<string, any>;
}
): AppError => {
if (error instanceof AppError) {
// If it's already an AppError and no overrides, return as is
if (!options?.statusCode && !options?.message && !options?.category) {
return error;
}
// Create a new AppError with the original as context
return new AppError(
options?.message || error.message,
options?.statusCode || error.status,
options?.category || error.category,
{
...(error.context || {}),
...(options?.context || {}),
originalError: error,
}
);
}
// Determine the appropriate error type based on status code
let errorType: ErrorType = "internal";
if (options?.statusCode) {
// Find the error type that matches the status code
const entry = Object.entries(ERROR_FACTORIES).find(([_, factory]) => factory.statusCode === options.statusCode);
if (entry) {
errorType = entry[0] as ErrorType;
}
}
// Return a new AppError using the factory
return handleError(error, {
errorType: errorType,
message: options?.message,
component: options?.context?.component || "unknown",
operation: options?.context?.operation || "convert-error",
extraContext: options?.context,
});
};
/**
* Check if an error is an AppError
*/
export const isAppError = (err: any, statusCode?: number): boolean => {
return err instanceof AppError && (!statusCode || err.status === statusCode);
};
// Export only the public API
export default {
handleError,
convertError,
isAppError,
};
@@ -1,384 +0,0 @@
import { ErrorRequestHandler, Request, Response, NextFunction } from "express";
import { env } from "@/env";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
import { manualLogger } from "../logger";
/**
* HTTP Status Codes
*/
export enum HttpStatusCode {
// 2xx Success
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NO_CONTENT = 204,
// 4xx Client Errors
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
CONFLICT = 409,
GONE = 410,
UNPROCESSABLE_ENTITY = 422,
TOO_MANY_REQUESTS = 429,
// 5xx Server Errors
INTERNAL_SERVER = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
}
/**
* Error categories to classify errors
*/
export enum ErrorCategory {
OPERATIONAL = "operational", // Expected errors that are part of normal operation (e.g. validation failures)
PROGRAMMING = "programming", // Unexpected errors that indicate bugs (e.g. null references)
SYSTEM = "system", // System errors (e.g. out of memory, connection failures)
FATAL = "fatal", // Severe errors that should crash the app (e.g. unrecoverable state)
}
/**
* Base Application Error Class
* All custom errors extend this class
*/
export class AppError extends Error {
readonly status: number;
readonly category: ErrorCategory;
readonly context?: Record<string, any>;
readonly isOperational: boolean; // Kept for backward compatibility
constructor(
message: string,
status: number = HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory = ErrorCategory.PROGRAMMING,
context?: Record<string, any>
) {
super(message);
// Set error properties
this.name = this.constructor.name;
this.status = status;
this.category = category;
this.isOperational = category === ErrorCategory.OPERATIONAL;
this.context = context;
// Capture stack trace, excluding the constructor call from the stack
Error.captureStackTrace(this, this.constructor);
// Automatically report the error (unless it's being constructed by the error utilities)
if (!context?.skipReporting) {
this.report();
}
}
/**
* Creates a formatted representation of the error
*/
output() {
return {
statusCode: this.status,
payload: {
statusCode: this.status,
error: this.getErrorName(),
message: this.message,
category: this.category,
},
headers: {},
};
}
/**
* Gets a descriptive name for the error based on status code
*/
private getErrorName(): string {
const statusCodes: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
410: "Gone",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
return statusCodes[this.status] || "Unknown Error";
}
/**
* Reports the error to logging and monitoring systems
*/
private report(): void {
// Different logging based on error category
if (this.category === ErrorCategory.OPERATIONAL) {
manualLogger.error(`Operational error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
context: this.context,
});
} else if (this.category === ErrorCategory.FATAL) {
manualLogger.error(`FATAL error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
});
} else {
manualLogger.error(`${this.category} error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
});
}
}
}
export class FatalError extends AppError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context);
}
}
/**
* Main Express error handler middleware
*/
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
// Already sent response, let default Express error handler deal with it
if (res.headersSent) {
return next(err);
}
// Convert to AppError if it's not already one
const error = handleError(err, {
component: "express",
operation: "error-handler",
extraContext: {
originalError: err,
url: req.originalUrl,
method: req.method,
},
});
// Normalize status code
const statusCode = error.status;
// Set the response status
res.status(statusCode);
// Set any custom headers if provided in the error object
if (err.headers && typeof err.headers === "object") {
Object.entries(err.headers).forEach(([key, value]) => {
res.set(key, value as string);
});
}
// Prepare error response
const errorResponse: {
error: {
message: string;
status: number;
stack?: string;
};
} = {
error: {
message:
error.category === ErrorCategory.OPERATIONAL || env.NODE_ENV !== "production"
? error.message
: "An unexpected error occurred",
status: statusCode,
},
};
// Add stack trace in non-production environments
if (env.NODE_ENV !== "production") {
errorResponse.error.stack = error.stack;
}
// Send the response
res.json(errorResponse);
// For fatal errors, log but NEVER terminate the app
if (error.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR OCCURRED BUT APP WILL CONTINUE RUNNING: ${error.message}`);
}
};
export const asyncHandler = (fn: Function) => {
return (req: any, res: any, next: any) => {
Promise.resolve(fn(req, res, next)).catch((error) => {
// Convert to AppError if needed and pass to Express error middleware
const appError = handleError(error, {
errorType: "internal",
component: "express",
operation: "route-handler",
extraContext: {
url: req.originalUrl,
method: req.method,
body: req.body,
query: req.query,
params: req.params,
},
});
next(appError);
});
};
};
export interface CatchAsyncOptions<T, E = Error> {
/** Default value to return in case of error, null by default */
defaultValue?: T | null;
/** Whether to report non-AppErrors automatically */
reportErrors?: boolean;
/** Whether to rethrow the error after handling it */
rethrow?: boolean;
/** Custom error transformer function */
transformError?: (error: unknown) => E;
/** Custom error handler function that runs before standard handling */
onError?: (error: unknown) => void | Promise<void>;
/** Custom handler for specific error types */
errorHandlers?: {
[key: string]: (error: any) => T | null | Promise<T>;
};
}
export const catchAsync = <T, E = Error>(
fn: () => Promise<T>,
context?: ErrorContext,
options: CatchAsyncOptions<T, E> = {}
): (() => Promise<T | null>) => {
const { defaultValue = null, onError, rethrow = false } = options;
return async () => {
try {
return await fn();
} catch (error) {
// Apply custom error handler if provided
if (onError) {
await Promise.resolve(onError(error));
}
reportError(error, context);
if (error instanceof AppError) {
error.context;
}
if (rethrow) {
// Use handleError to ensure consistent error handling when rethrowing
handleError(error, {
component: context?.extra?.component || "unknown",
operation: context?.extra?.operation || "unknown",
extraContext: {
...context,
...(error instanceof AppError ? error.context : {}),
originalError: error,
},
throw: true,
});
}
return defaultValue;
}
};
};
/**
* Set up global error handlers for uncaught exceptions and unhandled rejections
* @param gracefulTerminationHandler Function to call for graceful termination
*/
export const setupGlobalErrorHandlers = (gracefulTerminationHandler: () => Promise<void>): void => {
// Handle promise rejections
process.on("unhandledRejection", (reason: unknown) => {
logger.error("Unhandled Promise Rejection", { reason });
// Convert to AppError and handle
const appError = handleError(reason, {
errorType: "internal",
message: reason instanceof Error ? reason.message : String(reason),
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
// Log the error but never terminate
logger.error(`Unhandled rejection caught and contained: ${appError.message}`);
});
// Handle exceptions
process.on("uncaughtException", (error: Error) => {
logger.error("Uncaught Exception", {
error: error.message,
stack: error.stack,
});
// Convert to AppError if needed
const appError = handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: {
source: "uncaughtException",
},
});
// Log the error but never terminate
logger.warn(`Uncaught exception contained: ${appError.message}`);
});
// Handle termination signals
process.on("SIGTERM", () => {
logger.info("SIGTERM received. Starting graceful termination...");
gracefulTerminationHandler();
});
process.on("SIGINT", () => {
logger.info("SIGINT received. Starting graceful termination...");
gracefulTerminationHandler();
});
};
/**
* Configure error handling middleware for the Express app
* @param app Express application instance
*/
export function configureErrorHandlers(app: any): void {
// Global error handling middleware
app.use(errorHandler);
// 404 handler must be last
app.use((_req: Request, _res: Response, next: NextFunction) => {
next(
handleError(null, {
errorType: "not-found",
message: "Resource not found",
component: "express",
operation: "route-handler",
extraContext: { path: _req.path },
throw: true,
})
);
});
}
@@ -1,45 +0,0 @@
import { AppError, ErrorCategory } from "./error-handler";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
export interface ErrorContext {
url?: string;
method?: string;
body?: any;
query?: any;
params?: any;
extra?: Record<string, any>;
}
/**
* Utility function to report errors that aren't instances of AppError
* AppError instances automatically report themselves on creation
* Only use this for external errors that don't use our error system
*/
export const reportError = (error: Error | unknown, context?: ErrorContext): void => {
if (error instanceof AppError) {
// if it's an app error, don't report it as it's already been reported
return;
}
logger.error(`External error: ${error instanceof Error ? error.stack || error.message : String(error)}`, {
error,
context,
});
};
export const handleFatalError = (error: Error | unknown, context?: ErrorContext): void => {
// Convert to fatal AppError
const fatalError = handleError(error, {
errorType: "fatal",
message: error instanceof Error ? error.message : String(error),
component: context?.extra?.component || "system",
operation: context?.extra?.operation || "fatal-error-handler",
extraContext: {
...context,
originalError: error,
},
});
process.emit("uncaughtException", fatalError);
};
@@ -1,158 +0,0 @@
import { handleError } from "./error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is required`,
component: 'validator',
operation: 'required',
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a string`,
component: 'validator',
operation: 'string',
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} cannot be empty`,
component: 'validator',
operation: 'notEmpty',
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a valid number`,
component: 'validator',
operation: 'number',
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a non-empty array`,
component: 'validator',
operation: 'nonEmptyArray',
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} has an invalid format`,
component: 'validator',
operation: 'match',
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: 'validator',
operation: 'oneOf',
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is invalid`,
component: 'validator',
operation: 'custom',
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
@@ -1,21 +0,0 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
+23 -35
View File
@@ -1,34 +1,20 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
import * as Y from "yjs"
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
export const DOCUMENT_EDITOR_EXTENSIONS = [
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
/**
* Extracts the text content from an HTML string
* @param html HTML string
* @returns text content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array
): {
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
@@ -36,11 +22,10 @@ export const getAllDocumentFormatsFromBinaryData = (
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -48,24 +33,27 @@ export const getAllDocumentFormatsFromBinaryData = (
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
};
}
export const getBinaryDataFromHTMLString = (
descriptionHTML: string
): {
contentBinary: Uint8Array;
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
} => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData,
};
};
contentBinary: encodedData
}
}
+48 -126
View File
@@ -1,151 +1,73 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
// editor types
import { EventToPayloadMap, TUserDetails, createRealtimeEvent } from "@plane/editor";
import { TUserDetails } from "@plane/editor";
// types
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// server agent
import { serverAgentManager } from "./agents/server-agent";
import { type HocusPocusServerContext } from "@/core/types/common.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
const server = Server.configure({
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
context,
// user id used as token for authentication
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext;
requestParameters: URLSearchParams;
token: string;
}) => {
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
console.error("Token parsing failed, using request headers:", error);
} finally {
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
handleError(null, {
errorType: "unauthorized",
message: "Credentials not provided",
component: "hocuspocus",
operation: "authenticate",
extraContext: { tokenProvided: !!token },
throw: true,
});
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
}
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie") ?? "";
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() ?? "";
context.parentId = requestParameters.get("parentPageId")?.toString() ?? undefined;
context.projectId = requestParameters.get("projectId")?.toString() ?? "";
context.teamspaceId = requestParameters.get("teamspaceId")?.toString() ?? "";
// set cookie in context, so it can be used throughout the ws connection
(context as HocusPocusServerContext).cookie = cookie;
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{ rethrow: true }
)();
try {
await handleAuthentication({
cookie,
userId,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
},
onStateless: async ({ payload, document, connection }) => {
return catchAsync(
async () => {
const payloadStr = payload as string;
// Function to safely parse JSON without throwing exceptions
const safeJsonParse = (str: string) => {
try {
return { success: true, data: JSON.parse(str) };
} catch (e) {
return { success: false, error: e };
}
};
// First check if this is a known document event
const documentEvent = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
if (documentEvent) {
const eventType = documentEvent as keyof EventToPayloadMap;
let eventData: Partial<EventToPayloadMap[typeof eventType]> = {
user_id: connection.context.userId,
};
if (eventType === "archived") {
eventData = {
...eventData,
archived_at: new Date().toISOString(),
};
}
const realtimeEvent = createRealtimeEvent({
action: eventType,
page_id: document.name,
descendants_ids: [],
data: eventData as EventToPayloadMap[typeof eventType],
workspace_slug: connection.context.workspaceSlug || "",
user_id: connection.context.userId || "",
});
// Broadcast the event
document.broadcastStateless(JSON.stringify(realtimeEvent));
return;
}
// If not a document event, try to parse as JSON
const parseResult = safeJsonParse(payloadStr);
if (parseResult.success && parseResult.data && typeof parseResult.data === "object") {
const parsedPayload = parseResult.data as {
workspaceSlug?: string;
projectId?: string;
action?: string;
};
// Handle synced action
if (parsedPayload.action === "synced" && parsedPayload.workspaceSlug) {
serverAgentManager.notifySyncTrigger(document.name, connection.context);
return;
}
}
},
{ extra: { operation: "stateless", payload } }
)();
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
extensions: [...extensions],
extensions,
debounce: 10000,
});
return server;
};
+7 -26
View File
@@ -1,46 +1,27 @@
// services
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
workspaceSlug: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId, workspaceSlug } = props;
const { cookie, userId } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
component: "authentication",
operation: "fetch-current-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
manualLogger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {
handleError(null, {
errorType: "unauthorized",
message: "Authentication failed: Token doesn't match the current user.",
component: "authentication",
operation: "validate-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
throw Error("Authentication failed: Token doesn't match the current user.");
}
return {
+112
View File
@@ -0,0 +1,112 @@
// helpers
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined,
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array",
);
}
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
throw error;
}
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string,
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(
workspaceSlug,
projectId,
pageId,
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
return contentBinary;
} catch (error) {
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
throw error;
}
};
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined,
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
try {
const response = await pageService.fetchDescriptionBinary(
workspaceSlug,
projectId,
pageId,
cookie,
);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
projectId,
pageId,
cookie,
);
if (binary) {
return binary;
}
}
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
throw error;
}
};
-135
View File
@@ -1,135 +0,0 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { shutdownManager } from "@/core/shutdown-manager";
interface RedisError extends Error {
code?: string;
}
export class RedisManager {
private static instance: RedisManager;
private client: Redis | null = null;
private hasEverConnected = false;
private readonly maxReconnectAttempts = 3;
// Private constructor to enforce singleton pattern
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public getClient(): Redis | null {
return this.client;
}
public async connect(): Promise<Redis | null> {
const redisUrl = getRedisUrl();
if (!redisUrl) {
shutdownManager.shutdown("Redis URL is not set, shutting down", 1);
return null;
}
this.client = new Redis(redisUrl, {
retryStrategy: (times: number): number | null => {
if (!this.hasEverConnected) {
// If we've never connected successfully, don't retry
logger.warn(
"Initial Redis connection attempt failed. Continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
return null;
} else {
// Once connected at least once, try a few times before giving up
if (times > this.maxReconnectAttempts) {
logger.error(`Exceeded ${this.maxReconnectAttempts} Redis reconnect attempts. Shutting down the server.`);
shutdownManager.shutdown("Redis connection lost and could not be recovered", 1);
return null; // This will never be reached due to shutdown, but needed for type safety
}
logger.warn(`Redis connection lost. Attempting to reconnect (#${times}) in 1000 ms...`);
return 1000; // wait 1 second between attempts
}
},
});
// Set up event handlers
this.client.on("connect", () => {
logger.info("Redis: connecting...");
});
this.client.on("ready", () => {
if (!this.hasEverConnected) {
logger.info("Redis: initial connection established and ready ✅");
} else {
logger.info("Redis: reconnected and ready ✅");
}
this.hasEverConnected = true;
});
this.client.on("error", (error: RedisError) => {
const fatalErrorCodes = [
"ENOTFOUND",
"ECONNREFUSED",
"ECONNRESET",
"ETIMEDOUT",
"EHOSTUNREACH",
"EPIPE",
"WRONGPASS",
"NOAUTH",
];
const fatalMessages = ["WRONGPASS", "NOAUTH", "READONLY", "LOADING", "CLUSTERDOWN", "CONNECTION_BROKEN"];
if (
(error?.code && fatalErrorCodes.includes(error.code)) ||
fatalMessages.some((msg) => error.message.includes(msg))
) {
if (this.client) this.client.disconnect();
shutdownManager.shutdown("Redis connection failed and could not be recovered", 1);
} else {
logger.warn("Non-fatal Redis error:", error);
}
});
this.client.on("close", () => {
logger.warn("Redis connection closed.");
});
this.client.on("reconnecting", (delay: number) => {
logger.info(`Redis: reconnecting in ${delay} ms...`);
});
// Wait for connection to be ready or fail
return new Promise<Redis | null>((resolve) => {
if (!this.client) {
resolve(null);
return;
}
this.client.once("ready", () => {
resolve(this.client);
});
this.client.once("error", () => {
// The retryStrategy will handle this, we just need to resolve with null
// if initial connection fails
if (!this.hasEverConnected) {
resolve(null);
}
});
});
}
public getStatus(): "connected" | "connecting" | "disconnected" | "not-configured" {
if (!this.client) return "not-configured";
const status = this.client.status;
if (status === "ready") return "connected";
if (status === "connect" || status === "reconnecting") return "connecting";
return "disconnected";
}
}
@@ -1,46 +0,0 @@
import axios, { AxiosInstance } from "axios";
import { config } from "dotenv";
config();
export const API_BASE_URL = process.env.API_BASE_URL ?? "";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
});
}
get(url: string, params = {}, config = {}) {
return this.axiosInstance.get(url, {
...params,
...config,
});
}
post(url: string, data = {}, config = {}) {
return this.axiosInstance.post(url, data, config);
}
put(url: string, data = {}, config = {}) {
return this.axiosInstance.put(url, data, config);
}
patch(url: string, data = {}, config = {}) {
return this.axiosInstance.patch(url, data, config);
}
delete(url: string, data?: any, config = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
request(config = {}) {
return this.axiosInstance(config);
}
}
+78
View File
@@ -0,0 +1,78 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
export class PageService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchDetails(
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
{
headers: {
Cookie: cookie,
},
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchDescriptionBinary(
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
{
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
responseType: "arraybuffer",
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateDescription(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
{
headers: {
Cookie: cookie,
},
}
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
@@ -1,19 +0,0 @@
// services
import { ProjectPageConfig } from "@/ce/document-types/project-page-handler";
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
/**
* Service for handling project page operations
*/
export class ProjectPageService extends BasePageService {
/**
* Gets the base URL path for project pages
*/
protected getBasePath<TConfig extends ProjectPageConfig>(params: IBasePageParams<TConfig>): string {
const { pageId, config } = params;
const { workspaceSlug, projectId } = config;
// Handle project pages
return `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}`;
}
}
@@ -1,14 +0,0 @@
// services
import { BasePageService, IBasePageParams } from "@/core/services/base-page.service";
export interface ServerAgentConfig {}
export class ServerAgentService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends ServerAgentConfig>(params: IBasePageParams<TConfig>): string {
const { pageId } = params;
return `/api/pages/${pageId}`;
}
}
@@ -1,20 +0,0 @@
// services
import { BasePageService, IBasePageParams, IUpdateDescriptionParams } from "@/core/services/base-page.service";
export interface SyncAgentConfig {}
export class SyncAgentService extends BasePageService {
/**
* Gets the base URL path for workspace pages
*/
protected getBasePath<TConfig extends SyncAgentConfig>(params: IBasePageParams<TConfig>): string {
const { pageId } = params;
return `/api/pages/${pageId}`;
}
async updateDescription<TConfig extends SyncAgentConfig>(params: IUpdateDescriptionParams<TConfig>) {
// no op
// since we can't prevent hocuspocus from updating the description after a
// sync event, we need to manually override the method to not do anything
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
export class UserService extends APIService {
constructor() {
-74
View File
@@ -1,74 +0,0 @@
import { logger } from "@plane/logger";
import { Server as HttpServer } from "http";
import { handleError } from "./helpers/error-handling/error-factory";
/**
* Handles graceful shutdown and process signal management for the HTTP server.
*/
class ShutdownManager {
private httpServer: HttpServer | null = null;
/**
* Register the HTTP server instance to be shut down later.
*/
register({ httpServer }: { httpServer: HttpServer }) {
this.httpServer = httpServer;
}
/**
* Register process termination signal handlers.
*/
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
handleError(reason, {
errorType: "internal",
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
});
}
/**
* Returns a handler that gracefully shuts down the HTTP server.
*/
private getGracefulTerminationHandler(): () => Promise<void> {
return async () => {
logger.info("Signal received, shutting down HTTP server");
await this.shutdown();
};
}
/**
* Gracefully shuts down the registered HTTP server.
*/
async shutdown(message?: string, exitCode = 0): Promise<void> {
logger.error(`Initiating graceful shutdown ${`${message ? `with message: ${message}` : ""}`}`);
if (this.httpServer) {
this.httpServer.closeAllConnections?.();
await new Promise<void>((resolve) => {
this.httpServer?.close(() => resolve());
});
}
process.exit(exitCode);
}
}
export const shutdownManager = new ShutdownManager();
+1 -6
View File
@@ -1,15 +1,10 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common";
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
projectId: string;
workspaceSlug: string;
documentType: TDocumentTypes;
userId: string;
agentId: string;
};
export type TConvertDocumentRequestBody = {
-62
View File
@@ -1,62 +0,0 @@
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Parameters for document fetch operations
*/
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
params: URLSearchParams;
}
/**
* Parameters for document store operations
*/
export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: any;
params: URLSearchParams;
}
/**
* Interface defining a document handler
*/
export interface DocumentHandler {
/**
* Fetch a document
*/
fetch: (params: DocumentFetchParams) => Promise<any>;
/**
* Store a document
*/
store: (params: DocumentStoreParams) => Promise<void>;
}
/**
* Handler context interface - extend this to add new criteria for handler selection
*/
export interface HandlerContext {
documentType?: TDocumentTypes;
agentId?: string;
}
/**
* Handler selector function type - determines if a handler should be used based on context
*/
export type HandlerSelector = (context: HandlerContext) => boolean;
/**
* Handler definition combining a selector and implementation
*/
export interface HandlerDefinition {
selector: HandlerSelector;
handler: DocumentHandler;
priority: number; // Higher number means higher priority
}
/**
* Type for a handler registration function
*/
export type RegisterHandler = (definition: HandlerDefinition) => void;
@@ -1,27 +0,0 @@
import { Hocuspocus } from "@hocuspocus/server";
import { BroadcastedEvent } from "@plane/editor";
import { CustomHocuspocusRedisExtension } from "@/core/extensions/redis";
import { ServerAgentManager } from "@/core/agents/server-agent";
export const broadcastMessageToPage = (
instance: Hocuspocus | ServerAgentManager,
documentName: string,
eventData: BroadcastedEvent
): boolean => {
const hocuspocusServer =
"hocuspocusServer" in instance ? (instance as ServerAgentManager).hocuspocusServer : instance;
if (!hocuspocusServer || !hocuspocusServer.documents) {
console.error("HocusPocus server not available or initialized");
return false;
}
const redisExtension = hocuspocusServer.configuration.extensions.find(
(ext) => ext instanceof CustomHocuspocusRedisExtension
) as CustomHocuspocusRedisExtension | undefined;
if (redisExtension) {
redisExtension.broadcastToDocument(documentName, eventData);
return true;
}
return false;
};
-115
View File
@@ -1,115 +0,0 @@
import * as Y from "yjs";
/**
* Recursively finds all XML elements in the tree that match the given criteria
*
* @param node The root node to start searching from (Y.XmlFragment or Y.XmlElement)
* @param nodeName The node name to match (e.g. "pageEmbedComponent")
* @param attributeName The attribute name to match
* @param attributeValue The attribute value to match or "*" for any value
* @returns An array of objects containing the matched node and its path information
*/
export function findAllElementsRecursive(
node: Y.XmlFragment | Y.XmlElement,
nodeName: string,
attributeName: string,
attributeValue: string,
path: string[] = []
): Array<{
node: Y.XmlElement;
parent: Y.XmlFragment | Y.XmlElement;
indexInParent: number;
path: string[];
}> {
const results: Array<{
node: Y.XmlElement;
parent: Y.XmlFragment | Y.XmlElement;
indexInParent: number;
path: string[];
}> = [];
const children = node.toArray();
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child instanceof Y.XmlElement) {
// Calculate the path to this node
const nodePath = [...path];
if (child.nodeName) {
nodePath.push(`${child.nodeName}[${i}]`);
}
// Check if the current element matches the criteria
if (child.nodeName === nodeName) {
const attrValue = child.getAttribute(attributeName);
// Match if the attribute value is the wildcard "*" or if it matches exactly
if (attributeValue === "*" || attrValue === attributeValue) {
results.push({
node: child,
parent: node,
indexInParent: i,
path: nodePath,
});
}
}
// Recursively search in this element's children
const nestedResults = findAllElementsRecursive(child, nodeName, attributeName, attributeValue, nodePath);
results.push(...nestedResults);
}
}
return results;
}
/**
* Get a string representation of the path to a node for logging purposes
*
* @param pathArray The path array from findAllElementsRecursive
* @returns A string representation of the path
*/
export function getPathString(pathArray: string[]): string {
if (pathArray.length === 0) {
return "root";
}
return pathArray.join(" > ");
}
/**
* Insert a new node after a specific target node in the XML tree
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
* @param newNode The new node to insert
*/
export function insertNodeAfter(
parent: Y.XmlFragment | Y.XmlElement,
targetIndex: number,
newNode: Y.XmlElement
): void {
parent.insert(targetIndex + 1, [newNode]);
}
/**
* Replace a node in the XML tree with a new node
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
* @param newNode The new node to replace the target node with
*/
export function replaceNode(parent: Y.XmlFragment | Y.XmlElement, targetIndex: number, newNode: Y.XmlElement): void {
parent.delete(targetIndex, 1);
parent.insert(targetIndex, [newNode]);
}
/**
* Delete a node from the XML tree
*
* @param parent The parent node containing the target node
* @param targetIndex The index of the target node in the parent
*/
export function deleteNode(parent: Y.XmlFragment | Y.XmlElement, targetIndex: number): void {
parent.delete(targetIndex, 1);
}
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/authentication.js"
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/fetch-document.js"
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/lib/update-document.js"
+1
View File
@@ -0,0 +1 @@
export * from "../../ce/types/common.js"
-44
View File
@@ -1,44 +0,0 @@
import * as dotenv from "dotenv";
import { z } from "zod";
// Load environment variables from .env file
dotenv.config();
// Define environment schema with validation
const envSchema = z.object({
// Server configuration
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.string().default("3000").transform(Number),
LIVE_BASE_PATH: z.string().default("/live"),
// CORS configuration
CORS_ALLOWED_ORIGINS: z.string().default("*"),
// Compression options
COMPRESSION_LEVEL: z.string().default("6").transform(Number),
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
// Hocuspocus server configuration
HOCUSPOCUS_URL: z.string().optional(),
HOCUSPOCUS_USERNAME: z.string().optional(),
HOCUSPOCUS_PASSWORD: z.string().optional(),
// Graceful termination timeout
SHUTDOWN_TIMEOUT: z.string().default("10000").transform(Number),
// Live server secret key
LIVE_SERVER_SECRET_KEY: z.string(),
});
// Validate the environment variables
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
process.exit(1);
}
return result.data;
}
// Export the validated environment
export const env = validateEnv();
-32
View File
@@ -1,32 +0,0 @@
import { Router } from "express";
import { registerControllers as registerRestControllers, registerWebSocketControllers } from "@plane/decorators";
import "reflect-metadata";
/**
* Register all controllers from the controllers array
* @param router Express router to register routes on
* @param controllers Array of controller classes to register
* @param dependencies Array of dependencies to pass to controllers
*/
export function registerControllers(router: Router, controllers: any[], dependencies: any[] = []): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketControllers(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestControllers(router, Controller);
}
});
}

Some files were not shown because too many files have changed in this diff Show More