Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53446340b | |||
| 9070acbbe8 | |||
| b5fe8a2825 | |||
| c4b3d52466 | |||
| f0dcf66167 | |||
| e9b011896d | |||
| d3c6e5ec94 | |||
| e10deb10f2 | |||
| 49fc6aa0a0 | |||
| 55e89cb8fc | |||
| 4d1e6c499f | |||
| 3a99ecf8f3 | |||
| ef5d481a19 | |||
| c8a800104c | |||
| e92b835869 | |||
| 7e5b5066c5 | |||
| 53b3358a63 | |||
| bf521b7b03 | |||
| 7607cc9b10 | |||
| d497304de5 | |||
| 8fa08b2506 | |||
| efc600ad8c | |||
| a3a1d141cb | |||
| dfce8c6278 | |||
| fab84eb058 | |||
| 60734b25ba | |||
| cd613e5f8f | |||
| a8d81656fc | |||
| dbe059b7b5 | |||
| c93f9fc865 | |||
| bcc8fb4d1d | |||
| b59e541b35 | |||
| 2b6e24d526 | |||
| 7793febcf8 | |||
| 06e4a1624c | |||
| 57ce2a5429 | |||
| 0887cbbda8 | |||
| e1227f0b58 | |||
| ea7b30bc9c | |||
| dfbd043e50 | |||
| 13a679437d | |||
| 78729277e8 | |||
| d191615a5e | |||
| 587cb3ecfe | |||
| b8d3b3c5eb |
@@ -11,7 +11,7 @@ import { Outlet } from "react-router";
|
||||
// components
|
||||
import { AdminHeader } from "@/components/common/header";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { NewUserPopup } from "@/components/new-user-popup";
|
||||
import { NewUserPopup } from "@/components/common/new-user-popup";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// local components
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { validateSlug, validateWorkspaceName } from "@plane/utils";
|
||||
// components
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
// hooks
|
||||
@@ -96,14 +97,7 @@ export function WorkspaceCreateForm() {
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "This is a required field.",
|
||||
validate: (value) =>
|
||||
/^[\w\s-]*$/.test(value) ||
|
||||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: "Limit your name to 80 characters.",
|
||||
},
|
||||
validate: (value) => validateWorkspaceName(value, true),
|
||||
}}
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<Input
|
||||
@@ -135,11 +129,7 @@ export function WorkspaceCreateForm() {
|
||||
control={control}
|
||||
name="slug"
|
||||
rules={{
|
||||
required: "The URL is a required field.",
|
||||
maxLength: {
|
||||
value: 48,
|
||||
message: "Limit your URL to 48 characters.",
|
||||
},
|
||||
validate: (value) => validateSlug(value),
|
||||
}}
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Input
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Input, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { Banner } from "@/components/common/banner";
|
||||
// local components
|
||||
import { FormHeader } from "../../../core/components/instance/form-header";
|
||||
import { FormHeader } from "@/components/instance/form-header";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import { authErrorHandler } from "./auth-helpers";
|
||||
@@ -146,7 +146,7 @@ export function InstanceSignInForm() {
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export function InstanceSignInForm() {
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<button
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { enableStaticRendering } from "mobx-react";
|
||||
// stores
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore extends CoreRootStore {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
hydrate(initialData: any) {
|
||||
super.hydrate(initialData);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
super.resetOnSignOut();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { Menu, Settings } from "lucide-react";
|
||||
// icons
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { BreadcrumbLink } from "../breadcrumb-link";
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
// local imports
|
||||
+29
-10
@@ -13,11 +13,11 @@ import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
import { getPasswordStrength, validatePersonName, validateCompanyName } from "@plane/utils";
|
||||
// components
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import { Banner } from "@/components/common/banner";
|
||||
import { FormHeader } from "@/components/instance/form-header";
|
||||
import { Banner } from "../common/banner";
|
||||
import { FormHeader } from "./form-header";
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
@@ -173,9 +173,15 @@ export function InstanceSetupForm() {
|
||||
inputSize="md"
|
||||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
onChange={(e) => {
|
||||
const validation = validatePersonName(e.target.value);
|
||||
if (validation === true || e.target.value === "") {
|
||||
handleFormChange("first_name", e.target.value);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
@@ -190,8 +196,14 @@ export function InstanceSetupForm() {
|
||||
inputSize="md"
|
||||
placeholder="Wright"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleFormChange("last_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
onChange={(e) => {
|
||||
const validation = validatePersonName(e.target.value);
|
||||
if (validation === true || e.target.value === "") {
|
||||
handleFormChange("last_name", e.target.value);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +222,7 @@ export function InstanceSetupForm() {
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
|
||||
autoComplete="on"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
|
||||
<p className="px-1 text-11 text-danger-primary">{errorData.message}</p>
|
||||
@@ -229,7 +241,13 @@ export function InstanceSetupForm() {
|
||||
inputSize="md"
|
||||
placeholder="Company name"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => handleFormChange("company_name", e.target.value)}
|
||||
onChange={(e) => {
|
||||
const validation = validateCompanyName(e.target.value, false);
|
||||
if (validation === true || e.target.value === "") {
|
||||
handleFormChange("company_name", e.target.value);
|
||||
}
|
||||
}}
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -250,7 +268,7 @@ export function InstanceSetupForm() {
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="on"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
@@ -294,6 +312,7 @@ export function InstanceSetupForm() {
|
||||
className="w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<button
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "ce/store/root.store";
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
import { createContext } from "react";
|
||||
// plane admin store
|
||||
import { RootStore } from "@/plane-admin/store/root.store";
|
||||
import { RootStore } from "../store/root.store";
|
||||
|
||||
let rootStore = new RootStore();
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
IInstanceConfig,
|
||||
} from "@plane/types";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import type { RootStore } from "@/store/root.store";
|
||||
|
||||
export interface IInstanceStore {
|
||||
// issues
|
||||
@@ -53,7 +53,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
// service
|
||||
instanceService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
isLoading: observable.ref,
|
||||
@@ -17,7 +17,7 @@ import { WorkspaceStore } from "./workspace.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export abstract class CoreRootStore {
|
||||
export class RootStore {
|
||||
theme: IThemeStore;
|
||||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { action, observable, makeObservable } from "mobx";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import type { RootStore } from "./root.store";
|
||||
|
||||
type TTheme = "dark" | "light";
|
||||
export interface IThemeStore {
|
||||
@@ -27,7 +27,7 @@ export class ThemeStore implements IThemeStore {
|
||||
isSidebarCollapsed: boolean | undefined = undefined;
|
||||
theme: string | undefined = undefined;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isNewUserPopup: observable.ref,
|
||||
@@ -11,7 +11,7 @@ import { EUserStatus } from "@plane/constants";
|
||||
import { AuthService, UserService } from "@plane/services";
|
||||
import type { IUser } from "@plane/types";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import type { RootStore } from "@/store/root.store";
|
||||
|
||||
export interface IUserStore {
|
||||
// observables
|
||||
@@ -36,7 +36,7 @@ export class UserStore implements IUserStore {
|
||||
userService;
|
||||
authService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
isLoading: observable.ref,
|
||||
@@ -10,7 +10,7 @@ import { action, observable, runInAction, makeObservable, computed } from "mobx"
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
|
||||
// root store
|
||||
import type { CoreRootStore } from "@/store/root.store";
|
||||
import type { RootStore } from "@/store/root.store";
|
||||
|
||||
export interface IWorkspaceStore {
|
||||
// observables
|
||||
@@ -37,7 +37,7 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
// services
|
||||
instanceWorkspaceService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable,
|
||||
@@ -9,12 +9,7 @@
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"package.json": ["./package.json"],
|
||||
"ce/*": ["./ce/*"],
|
||||
"@/app/*": ["./app/*"],
|
||||
"@/*": ["./core/*"],
|
||||
"@/plane-admin/*": ["./ce/*"],
|
||||
"@/ce/*": ["./ce/*"],
|
||||
"@/styles/*": ["./styles/*"]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import random
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate
|
||||
|
||||
@@ -101,6 +105,15 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_name = data.get("name", None)
|
||||
project_identifier = data.get("identifier", None)
|
||||
|
||||
if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name):
|
||||
raise serializers.ValidationError("Project name cannot contain special characters.")
|
||||
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
if data.get("project_lead", None) is not None:
|
||||
# Check if the project lead is a member of the workspace
|
||||
if not WorkspaceMember.objects.filter(
|
||||
@@ -160,6 +173,15 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
|
||||
read_only_fields = ProjectCreateSerializer.Meta.read_only_fields
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
project_name = validated_data.get("name", None)
|
||||
project_identifier = validated_data.get("identifier", None)
|
||||
|
||||
if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name):
|
||||
raise serializers.ValidationError("Project name cannot contain special characters.")
|
||||
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
"""Update a project"""
|
||||
if (
|
||||
validated_data.get("default_state", None) is not None
|
||||
@@ -210,6 +232,15 @@ class ProjectSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_name = data.get("name", None)
|
||||
project_identifier = data.get("identifier", None)
|
||||
|
||||
if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name):
|
||||
raise serializers.ValidationError("Project name cannot contain special characters.")
|
||||
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
# Check project lead should be a member of the workspace
|
||||
if (
|
||||
data.get("project_lead", None) is not None
|
||||
|
||||
@@ -414,7 +414,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
||||
{"error": "Archived module cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
|
||||
serializer = ModuleUpdateSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
||||
@@ -19,6 +19,9 @@ class APITokenSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
"workspace",
|
||||
"user",
|
||||
"is_active",
|
||||
"last_used",
|
||||
"user_type",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from django.db.models import Max
|
||||
@@ -37,6 +40,9 @@ class ProjectSerializer(BaseSerializer):
|
||||
project_id = self.instance.id if self.instance else None
|
||||
workspace_id = self.context["workspace_id"]
|
||||
|
||||
if re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, name):
|
||||
raise serializers.ValidationError(detail="PROJECT_NAME_CANNOT_CONTAIN_SPECIAL_CHARACTERS")
|
||||
|
||||
project = Project.objects.filter(name=name, workspace_id=workspace_id)
|
||||
|
||||
if project_id:
|
||||
@@ -53,6 +59,9 @@ class ProjectSerializer(BaseSerializer):
|
||||
project_id = self.instance.id if self.instance else None
|
||||
workspace_id = self.context["workspace_id"]
|
||||
|
||||
if re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, identifier):
|
||||
raise serializers.ValidationError(detail="PROJECT_IDENTIFIER_CANNOT_CONTAIN_SPECIAL_CHARACTERS")
|
||||
|
||||
project = Project.objects.filter(identifier=identifier, workspace_id=workspace_id)
|
||||
|
||||
if project_id:
|
||||
|
||||
@@ -111,7 +111,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
invite_link = serializers.SerializerMethodField()
|
||||
|
||||
def get_invite_link(self, obj):
|
||||
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
|
||||
return f"/workspace-invitations/?invitation_id={obj.id}&slug={obj.workspace.slug}&token={obj.token}"
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
@@ -579,7 +579,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=pk)
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
|
||||
@@ -60,7 +60,11 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(pk=pk)
|
||||
issue_attachment = FileAsset.objects.filter(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
).first()
|
||||
if not issue_attachment:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery, Count
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
@@ -28,18 +28,18 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
DeployBoard,
|
||||
ProjectUserProperty,
|
||||
Intake,
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectNetwork,
|
||||
ProjectUserProperty,
|
||||
State,
|
||||
DEFAULT_STATES,
|
||||
UserFavorite,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.db.models.intake import IntakeIssueStatus
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
@@ -50,11 +50,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
use_read_replica = True
|
||||
|
||||
def get_queryset(self):
|
||||
sort_order = ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
sort_order = ProjectUserProperty.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).values("sort_order")
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -140,11 +139,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def list(self, request, slug):
|
||||
sort_order = ProjectMember.objects.filter(
|
||||
member=self.request.user,
|
||||
sort_order = ProjectUserProperty.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).values("sort_order")
|
||||
|
||||
projects = (
|
||||
@@ -157,6 +155,15 @@ class ProjectViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(
|
||||
intake_count=Count(
|
||||
"project_intakeissue",
|
||||
filter=Q(
|
||||
project_intakeissue__status=IntakeIssueStatus.PENDING.value,
|
||||
project_intakeissue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(inbox_view=F("intake_view"))
|
||||
.annotate(sort_order=Subquery(sort_order))
|
||||
.distinct()
|
||||
@@ -167,6 +174,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
"sort_order",
|
||||
"logo_props",
|
||||
"member_role",
|
||||
"intake_count",
|
||||
"archived_at",
|
||||
"workspace",
|
||||
"cycle_view",
|
||||
|
||||
@@ -49,6 +49,7 @@ from plane.bgtasks.workspace_seed_task import workspace_seed
|
||||
from plane.bgtasks.event_tracking_task import track_event
|
||||
from plane.utils.url import contains_url
|
||||
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
|
||||
from plane.utils.csv_utils import sanitize_csv_row
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -81,12 +82,14 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value([
|
||||
{
|
||||
"key": "DISABLE_WORKSPACE_CREATION",
|
||||
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||
}
|
||||
])
|
||||
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "DISABLE_WORKSPACE_CREATION",
|
||||
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if DISABLE_WORKSPACE_CREATION == "1":
|
||||
return Response(
|
||||
@@ -369,7 +372,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
"""Generate CSV buffer from rows."""
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
[writer.writerow(row) for row in rows]
|
||||
[writer.writerow(sanitize_csv_row(row)) for row in rows]
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer
|
||||
|
||||
|
||||
@@ -163,10 +163,10 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, pk):
|
||||
workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug)
|
||||
|
||||
email = request.data.get("email", "")
|
||||
token = request.data.get("token", "")
|
||||
|
||||
# Check the email
|
||||
if email == "" or workspace_invite.email != email:
|
||||
# Validate the token to verify the user received the invitation email
|
||||
if not token or workspace_invite.token != token:
|
||||
return Response(
|
||||
{"error": "You do not have permission to join the workspace"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -180,7 +180,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
|
||||
if workspace_invite.accepted:
|
||||
# Check if the user created account after invitation
|
||||
user = User.objects.filter(email=email).first()
|
||||
user = User.objects.filter(email=workspace_invite.email).first()
|
||||
|
||||
# If the user is present then create the workspace member
|
||||
if user is not None:
|
||||
|
||||
@@ -46,7 +46,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
@@ -58,7 +58,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="cancelled",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
@@ -70,7 +70,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="started",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
@@ -82,7 +82,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="unstarted",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
@@ -94,7 +94,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="backlog",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
|
||||
@@ -85,8 +85,8 @@ class Adapter:
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
payload={"email": email},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -13,6 +13,7 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"USER_ACCOUNT_DEACTIVATED": 5019,
|
||||
# Password strength
|
||||
"INVALID_PASSWORD": 5020,
|
||||
"PASSWORD_TOO_WEAK": 5021,
|
||||
"SMTP_NOT_CONFIGURED": 5025,
|
||||
# Sign Up
|
||||
"USER_ALREADY_EXIST": 5030,
|
||||
|
||||
@@ -145,8 +145,8 @@ class ResetPasswordEndpoint(View):
|
||||
results = zxcvbn(password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
|
||||
@@ -83,8 +83,8 @@ class ChangePasswordEndpoint(APIView):
|
||||
results = zxcvbn(new_password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"],
|
||||
error_message="INVALID_NEW_PASSWORD",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -139,8 +139,8 @@ class ResetPasswordSpaceEndpoint(View):
|
||||
results = zxcvbn(password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
)
|
||||
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -13,7 +13,6 @@ from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.db.models import Q, Case, Value, When
|
||||
from django.db import models
|
||||
from django.db.models.functions import Concat
|
||||
@@ -22,8 +21,10 @@ from django.db.models.functions import Concat
|
||||
from plane.db.models import Issue
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.csv_utils import sanitize_csv_row
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
@@ -52,7 +53,7 @@ def send_export_email(email, slug, csv_buffer, rows):
|
||||
"""Helper function to send export email."""
|
||||
subject = "Your Export is ready"
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
|
||||
@@ -180,7 +181,7 @@ def generate_csv_from_rows(rows):
|
||||
"""Generate CSV buffer from rows."""
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
[writer.writerow(row) for row in rows]
|
||||
[writer.writerow(sanitize_csv_row(row)) for row in rows]
|
||||
return csv_buffer
|
||||
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ from django.template.loader import render_to_string
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, Issue, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti
|
||||
"entity_type": "issue",
|
||||
}
|
||||
html_content = render_to_string("emails/notifications/issue-updates.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
|
||||
@@ -12,10 +12,10 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
|
||||
html_content = render_to_string("emails/auth/forgot_password.html", context)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -12,10 +12,10 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def magic_link(email, key, token):
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -11,11 +11,11 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import ProjectMember
|
||||
from plane.db.models import User
|
||||
@@ -59,7 +59,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id):
|
||||
|
||||
# Render the email template
|
||||
html_content = render_to_string("emails/notifications/project_addition.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
# Initialize the connection
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -12,11 +12,11 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectMemberInvite, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def project_invitation(email, project_id, token, current_site, invitor):
|
||||
|
||||
html_content = render_to_string("emails/invitations/project_invitation.html", context)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
project_member_invite.message = text_content
|
||||
project_member_invite.save()
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -16,6 +15,7 @@ from celery import shared_task
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def user_activation_email(current_site, user_id):
|
||||
# Send email to user
|
||||
html_content = render_to_string("emails/user/user_activation.html", context)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -16,6 +15,7 @@ from celery import shared_task
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def user_deactivation_email(current_site, user_id):
|
||||
# Send email to user
|
||||
html_content = render_to_string("emails/user/user_deactivation.html", context)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
|
||||
@@ -11,10 +11,10 @@ from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def send_email_update_magic_code(email, token):
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
@@ -87,7 +87,7 @@ def send_email_update_confirmation(email):
|
||||
context = {"email": email}
|
||||
|
||||
html_content = render_to_string("emails/user/email_updated.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -20,7 +20,6 @@ from django.db.models import Prefetch
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Module imports
|
||||
@@ -51,6 +50,7 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
@@ -222,7 +222,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s
|
||||
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string("emails/notifications/webhook-deactivate.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
# Set the email connection
|
||||
connection = get_connection(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
import socket
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -26,7 +27,7 @@ DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoP
|
||||
def validate_url_ip(url: str) -> None:
|
||||
"""
|
||||
Validate that a URL doesn't point to a private/internal IP address.
|
||||
Only checks if the hostname is a direct IP address.
|
||||
Resolves hostnames to IPs before checking.
|
||||
|
||||
Args:
|
||||
url: The URL to validate
|
||||
@@ -38,17 +39,31 @@ def validate_url_ip(url: str) -> None:
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
# Only allow HTTP and HTTPS to prevent file://, gopher://, etc.
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
# Resolve hostname to IP addresses — this catches domain names that
|
||||
# point to internal IPs (e.g. attacker.com -> 169.254.169.254)
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
# Not an IP address (it's a domain name), nothing to check here
|
||||
return
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise ValueError("Hostname could not be resolved")
|
||||
|
||||
# It IS an IP address - check if it's private/internal
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
if not addr_info:
|
||||
raise ValueError("No IP addresses found for the hostname")
|
||||
|
||||
# Check every resolved IP against blocked ranges to prevent SSRF
|
||||
for addr in addr_info:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
|
||||
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
@@ -74,11 +89,23 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
validate_url_ip(final_url)
|
||||
|
||||
try:
|
||||
response = requests.get(final_url, headers=headers, timeout=1)
|
||||
final_url = response.url # Get the final URL after any redirects
|
||||
# Manually follow redirects to validate each URL before requesting
|
||||
redirect_count = 0
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
# check for redirected url also
|
||||
validate_url_ip(final_url)
|
||||
while response.is_redirect and redirect_count < MAX_REDIRECTS:
|
||||
redirect_url = response.headers.get("Location")
|
||||
if not redirect_url:
|
||||
break
|
||||
# Resolve relative redirects against current URL
|
||||
final_url = urljoin(final_url, redirect_url)
|
||||
# Validate the redirect target BEFORE making the request
|
||||
validate_url_ip(final_url)
|
||||
redirect_count += 1
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
logger.warning(f"Too many redirects for URL: {url}")
|
||||
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
title_tag = soup.find("title")
|
||||
@@ -134,7 +161,9 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
|
||||
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"])
|
||||
favicon_href = urljoin(base_url, favicon_tag["href"])
|
||||
validate_url_ip(favicon_href)
|
||||
return favicon_href
|
||||
|
||||
# Fallback to /favicon.ico
|
||||
parsed_url = urlparse(base_url)
|
||||
@@ -142,7 +171,9 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
|
||||
|
||||
# Check if fallback exists
|
||||
try:
|
||||
response = requests.head(fallback_url, timeout=2)
|
||||
validate_url_ip(fallback_url)
|
||||
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
|
||||
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except requests.RequestException as e:
|
||||
@@ -173,6 +204,8 @@ def fetch_and_encode_favicon(
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
validate_url_ip(favicon_url)
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||
|
||||
# Get content type
|
||||
|
||||
@@ -11,11 +11,11 @@ from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
|
||||
# Relative link
|
||||
relative_link = (
|
||||
f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
|
||||
f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&slug={workspace.slug}&token={token}" # noqa: E501
|
||||
)
|
||||
|
||||
# The complete url including the domain
|
||||
@@ -57,7 +57,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
|
||||
html_content = render_to_string("emails/invitations/workspace_invitation.html", context)
|
||||
|
||||
text_content = strip_tags(html_content)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
workspace_member_invite.message = text_content
|
||||
workspace_member_invite.save()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.27 on 2026-02-09 09:37
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0118_remove_workspaceuserproperties_product_tour_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='estimatepoint',
|
||||
name='key',
|
||||
field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-17 10:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0119_alter_estimatepoint_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issueview',
|
||||
name='archived_at',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -38,7 +38,7 @@ class Estimate(ProjectBaseModel):
|
||||
|
||||
class EstimatePoint(ProjectBaseModel):
|
||||
estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points")
|
||||
key = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)])
|
||||
key = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
description = models.TextField(blank=True)
|
||||
value = models.CharField(max_length=255)
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ class Project(BaseModel):
|
||||
"""Return name of the project"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
FORBIDDEN_IDENTIFIER_CHARS_PATTERN = r"^.*[&+,:;$^}{*=?@#|'<>.()%!-].*$"
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
["identifier", "workspace", "deleted_at"],
|
||||
|
||||
@@ -68,6 +68,7 @@ class IssueView(WorkspaceBaseModel):
|
||||
logo_props = models.JSONField(default=dict)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views")
|
||||
is_locked = models.BooleanField(default=False)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue View"
|
||||
|
||||
@@ -191,8 +191,8 @@ class InstanceAdminSignUpEndpoint(View):
|
||||
results = zxcvbn(password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"],
|
||||
error_message="INVALID_ADMIN_PASSWORD",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
payload={
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
|
||||
@@ -67,6 +67,11 @@ class ProjectMembersEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Invalid anchor"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
members = ProjectMember.objects.filter(
|
||||
project=deploy_board.project,
|
||||
@@ -75,10 +80,7 @@ class ProjectMembersEndpoint(BaseAPIView):
|
||||
).values(
|
||||
"id",
|
||||
"member",
|
||||
"member__first_name",
|
||||
"member__last_name",
|
||||
"member__display_name",
|
||||
"project",
|
||||
"workspace",
|
||||
"member__avatar",
|
||||
)
|
||||
return Response(members, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -142,7 +142,7 @@ class TestApiTokenEndpoint:
|
||||
"""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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
@@ -159,7 +159,7 @@ class TestApiTokenEndpoint:
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
@@ -178,7 +178,7 @@ class TestApiTokenEndpoint:
|
||||
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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
@@ -192,7 +192,7 @@ class TestApiTokenEndpoint:
|
||||
"""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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
@@ -207,7 +207,7 @@ class TestApiTokenEndpoint:
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
@@ -226,7 +226,7 @@ class TestApiTokenEndpoint:
|
||||
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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
@@ -242,7 +242,7 @@ class TestApiTokenEndpoint:
|
||||
# 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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
@@ -258,7 +258,7 @@ class TestApiTokenEndpoint:
|
||||
"""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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
@@ -282,7 +282,7 @@ class TestApiTokenEndpoint:
|
||||
"""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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
@@ -300,7 +300,7 @@ class TestApiTokenEndpoint:
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
@@ -320,7 +320,7 @@ class TestApiTokenEndpoint:
|
||||
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})
|
||||
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
@@ -333,6 +333,56 @@ class TestApiTokenEndpoint:
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_token(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that token value cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_token = create_api_token_for_user.token
|
||||
update_data = {"token": "plane_api_malicious_token_value"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.token == original_token
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_user_type(self, session_client, create_user, create_api_token_for_user):
|
||||
"""Test that user_type cannot be modified via PATCH"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {"user_type": 1}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.user_type == 0
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_cannot_modify_service_token(self, session_client, create_user):
|
||||
"""Test that service tokens cannot be modified through user token endpoint"""
|
||||
# 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-details", kwargs={"pk": service_token.pk})
|
||||
update_data = {"label": "Hacked Service Token"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
service_token.refresh_from_db()
|
||||
assert service_token.label == "Service Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
@@ -341,9 +391,9 @@ class TestApiTokenEndpoint:
|
||||
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"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
|
||||
@@ -139,8 +139,6 @@ ATTRIBUTES = {
|
||||
"rowspan",
|
||||
"colwidth",
|
||||
"background",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"td": {
|
||||
@@ -150,8 +148,6 @@ ATTRIBUTES = {
|
||||
"background",
|
||||
"textColor",
|
||||
"textcolor",
|
||||
"hideContent",
|
||||
"hidecontent",
|
||||
"style",
|
||||
},
|
||||
"tr": {"background", "textColor", "textcolor", "style"},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# CSV utility functions for safe export
|
||||
# Characters that trigger formula evaluation in spreadsheet applications
|
||||
_CSV_FORMULA_TRIGGERS = frozenset(("=", "+", "-", "@", "\t", "\r", "\n"))
|
||||
|
||||
|
||||
def sanitize_csv_value(value):
|
||||
"""Sanitize a value for CSV export to prevent formula injection.
|
||||
|
||||
Prefixes string values starting with formula-triggering characters
|
||||
with a single quote so spreadsheet applications treat them as text
|
||||
instead of evaluating them as formulas.
|
||||
|
||||
See: https://owasp.org/www-community/attacks/CSV_Injection
|
||||
"""
|
||||
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_csv_row(row):
|
||||
"""Sanitize all values in a CSV row."""
|
||||
return [sanitize_csv_value(v) for v in row]
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
def generate_plain_text_from_html(html_content):
|
||||
"""
|
||||
Generate clean plain text from HTML email template.
|
||||
Removes all HTML tags, CSS styles, and excessive whitespace.
|
||||
|
||||
Args:
|
||||
html_content (str): The HTML content to convert to plain text
|
||||
|
||||
Returns:
|
||||
str: Clean plain text without HTML tags, styles, or excessive whitespace
|
||||
"""
|
||||
# Remove style tags and their content
|
||||
html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# Strip HTML tags
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Remove excessive empty lines
|
||||
text_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", text_content)
|
||||
|
||||
# Ensure there's a leading and trailing whitespace
|
||||
text_content = "\n\n" + text_content.lstrip().rstrip() + "\n\n"
|
||||
|
||||
return text_content
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user