Compare commits

..
Author SHA1 Message Date
sriram veeraghantaandGitHub d0a4adc55b release: v1.3.1 #8917 2026-05-15 01:39:46 +05:30
sriram veeraghantaandGitHub 1dabc632bf fix: pnpm path for Docker builds (#9079)
Add $PNPM_HOME/bin to PATH so corepack-installed pnpm binaries are
resolvable during Docker builds.
2026-05-15 01:05:14 +05:30
sriram veeraghantaandGitHub 761c999e0c fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets (#9078)
* fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets

The IP-based allowlist alone isn't practical for containerised deployments
where service IPs are dynamic. Adds a hostname-based bypass for trusted
internal services (e.g. Silo via docker-compose / k8s service DNS) and
makes the previously hardcoded ["plane.so"] domain blocklist configurable
via WEBHOOK_DISALLOWED_DOMAINS.

- validate_url accepts allowed_hosts (exact, case-insensitive match;
  skips DNS lookup for trusted names)
- WebhookSerializer wires both settings through and lets allowlisted
  hosts bypass the disallowed-domain check
- Exposes WEBHOOK_ALLOWED_HOSTS in aio/cli deployment env files

* fix: default WEBHOOK_DISALLOWED_DOMAINS to empty for self-hosted

* fix: pass WEBHOOK_ALLOWED_HOSTS to send-time webhook re-validation
2026-05-15 00:57:39 +05:30
sriram veeraghantaandGitHub 32fb88ab24 chore(deps): bump axios, uuid and add security overrides (#8930)
* chore(deps): bump axios, uuid and add security overrides

Bump axios 1.15.0 → 1.15.2 and uuid 13.0.0 → 14.0.0 in the catalog,
and add pnpm overrides pinning postcss >=8.5.10, follow-redirects
>=1.16.0, and routing axios/uuid through the catalog.

* fix: overrides
2026-04-25 17:40:33 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
03a2be84b7 chore(deps): bump lxml (#8925)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [lxml](https://github.com/lxml/lxml).


Updates `lxml` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-6.0.0...lxml-6.1.0)

---
updated-dependencies:
- dependency-name: lxml
  dependency-version: 6.1.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 13:03:43 +05:30
sriramveeraghanta c62930ebcf chore: bump up the package version 2026-04-20 17:20:12 +05:30
sriram veeraghantaandGitHub f1d567accc chore: add Claude Code skills for PR descriptions and release notes (#8920)
* chore: add Claude Code skills for PR descriptions and release notes

* chore(skills): update release-notes branches to canary->master and example version to v1.3.0

* chore(skills): address PR review comments

- pr-description: infer base branch from PR metadata, fix Improvement wording, reference template's screenshot placeholder verbatim
- release-notes: add `text` language to unlabeled fenced code block
2026-04-20 17:17:54 +05:30
sriram veeraghantaandGitHub 62b2d1b207 chore: update CODEOWNERS for apps and deployments (#8919)
* chore: update CODEOWNERS for apps and deployments

Assign owners per app/area so reviews are routed to the right
maintainers.

* chore: update the codeowners
2026-04-20 17:17:34 +05:30
da41f14a05 chore(ci): suppress CodeQL file coverage deprecation warning (#8916)
* chore(ci): suppress CodeQL file coverage deprecation warning

Explicitly opt into the new default behavior where CodeQL skips
computing file coverage information on pull requests for improved
analysis performance.

* Update .github/workflows/codeql.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 15:39:30 +05:30
sriram veeraghantaandGitHub aea66f53f4 fix: sanitize filenames in upload paths to prevent path traversal (#8879)
* fix: sanitize filenames in upload paths to prevent path traversal (GHSA-v57h-5999-w7xp)

Add server-side filename sanitization across all file upload endpoints
to prevent path traversal sequences (../) in user-supplied filenames
from being incorporated into S3 object keys. While S3 keys are flat
strings and not vulnerable to filesystem traversal, this adds
defense-in-depth and prevents S3 key pollution.

Changes:
- Add sanitize_filename() utility in path_validator.py
- Sanitize filenames in get_upload_path() for FileAsset and IssueAttachment models
- Sanitize name parameter in all upload view endpoints

* fix: address PR review feedback on filename sanitization

- Remove unused `import re`
- Normalize backslashes to forward slashes before os.path.basename()
  so Windows-style paths (e.g. ..\..\..\evil.txt) are handled on POSIX
- Strip whitespace before removing leading dots so " .env" is caught
- Return None instead of "unnamed" for empty input so existing
  `if not name` validation guards remain effective
- Add `or "unnamed"` fallback at call sites that lack a name guard

* fix: use random hex name as fallback in get_upload_path instead of "unnamed"

* fix: resolve ruff E501 line too long in DuplicateAssetEndpoint
2026-04-20 15:33:30 +05:30
Saurabh KumarandGitHub 45b4fc8932 [SILO-1158] chore: add context for project in relations API (#8860)
* add context for project in relations API

* modify issue relation serializer
2026-04-20 15:29:28 +05:30
sriram veeraghantaandGitHub a8a16c8ba0 fix: replace IS_SELF_MANAGED with WEBHOOK_ALLOWED_IPS allowlist (#8884)
* fix: replace IS_SELF_MANAGED toggle with explicit WEBHOOK_ALLOWED_IPS allowlist

Instead of blanket-allowing all private IPs on self-managed deployments,
webhook URL validation now blocks all private/internal IPs by default and
only permits specific networks listed in the WEBHOOK_ALLOWED_IPS env
variable (comma-separated IPs/CIDRs).

* fix: address PR review comments for webhook SSRF protection

- Sanitize error messages to avoid leaking internal details to clients
- Guard against TypeError with mixed IPv4/IPv6 allowlist networks
- Re-validate webhook URL at send time to prevent DNS-rebinding
- Add unit tests for mixed-version IP network allowlists
2026-04-20 15:28:33 +05:30
sriram veeraghantaandGitHub ac11c3ef79 fix: enforce workspace membership on V2 asset endpoints (#8885)
WorkspaceFileAssetEndpoint had no authorization checks beyond
authentication, allowing any logged-in user to create, read, patch,
and delete assets in any workspace by slug. DuplicateAssetEndpoint
only authorized the destination workspace, letting users copy assets
from workspaces they don't belong to.

Add @allow_permission decorators to all WorkspaceFileAssetEndpoint
methods and scope DuplicateAssetEndpoint's source asset lookup to
workspaces where the caller is an active member.

Ref: GHSA-qw87-v5w3-6vxx
2026-04-20 15:26:59 +05:30
Phạm Nguyên PhươngandGitHub 13db2f883f enhance sub-issue query performance with optimized annotations and subqueries (#8889) 2026-04-14 13:54:28 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bbf14fba31 chore(deps): bump pytest (#8891)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [pytest](https://github.com/pytest-dev/pytest).


Updates `pytest` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 13:54:04 +05:30
Anmol Singh BhatiaandGitHub db3c8f27dc [WEB-6840] feat: skip role & use-case steps for self-hosted instances (#8890) 2026-04-13 18:24:12 +05:30
sriram veeraghantaandGitHub cf696d200d release: v1.3.0 #8835 2026-04-06 20:00:08 +05:30
54 changed files with 912 additions and 403 deletions
+58
View File
@@ -0,0 +1,58 @@
---
name: pr-description
description: Generate a PR description following the project's GitHub PR template. Analyzes the current branch's changes against the base branch to produce a complete, filled-out PR description.
user_invocable: true
---
# PR Description Generator
Generate a pull request description based on the project's PR template at `.github/pull_request_template.md`.
## Steps
1. **Determine the base branch**: Prefer the PR's actual `baseRefName` (via `gh pr view <PR> --json baseRefName`) when a PR exists. Otherwise default by intent — feature PRs target `preview`, release PRs target `master`. If still ambiguous, ask the user.
2. **Analyze changes**: Run the following to understand what changed:
- `git log <base>...HEAD --oneline` to see all commits on this branch
- `git diff <base>...HEAD --stat` to see which files changed
- `git diff <base>...HEAD` to read the actual diff (use `--no-color`)
- If the diff is very large, focus on the most important files first
3. **Fill out the PR template** with the following sections:
### Description
Write a clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention any important implementation decisions.
### Type of Change
Check the appropriate box(es) based on the changes:
- Bug fix (non-breaking change which fixes an issue)
- Feature (non-breaking change which adds functionality)
- Improvement (non-breaking change that improves existing functionality)
- Code refactoring
- Performance improvements
- Documentation update
### Screenshots and Media
Leave this section for the user to fill in, preserving the existing placeholder comment from `.github/pull_request_template.md` verbatim rather than introducing different text.
### Test Scenarios
Based on the code changes, suggest specific test scenarios that should be verified. Be concrete (e.g., "Navigate to project settings and verify the new toggle works") rather than generic.
### References
- If commit messages or branch name reference a work item identifier (e.g., `WEB-1234`), include it
- If the user provides a linked issue, include it
- If Sentry issue links or IDs (e.g., `SENTRY-ABC123`, Sentry URLs) were mentioned earlier in the conversation, include them as references
4. **Output format**: Print the filled-out markdown template so the user can copy it directly. Do NOT wrap it in a code fence — output the raw markdown.
## Guidelines
- Keep the description concise but informative
- Use bullet points for multiple changes
- Focus on user-facing impact, not implementation details
- If the branch has a Plane work item ID in its name (e.g., `WEB-1234`), reference it
- Don't fabricate test scenarios that aren't relevant to the actual changes
+147
View File
@@ -0,0 +1,147 @@
---
name: release-notes
description: "Generate release notes for a Plane release PR in `makeplane/plane` (semver, e.g. `release: vX.Y.Z`). Reads PR commits, filters out noise, categorizes by conventional-commit type, optionally enriches via Plane MCP, and writes the result as the PR description."
user_invocable: true
---
# Release Notes Generator
Generate structured release notes from a Plane release PR by parsing its commit list, then update the PR description.
## Versioning
Plane community uses **semver** (`vX.Y.Z`, major.minor.patch) for releases.
- PR title format: `release: vX.Y.Z`
- Source branch: `canary`
- Target branch: `master`
## When to Use
- User links/mentions a Plane release PR (e.g. `release: v1.3.0`) and asks for release notes
- User asks to "create release notes" / "update PR description" for a release PR in `makeplane/plane`
- The branch is named `canary` or `release/x.y.z` and the base is `master`
## Steps
### 1. Fetch commits
```bash
gh pr view <PR_NUM> --json title,body,baseRefName,headRefName,commits \
--jq '.commits[] | .messageHeadline + "\n---BODY---\n" + .messageBody + "\n===END==="'
```
For a quick scan first:
```bash
gh pr view <PR_NUM> --json commits \
--jq '.commits[] | {oid: .oid[0:10], message: .messageHeadline}'
```
### 2. Filter out noise
**Always exclude** these commits — mechanical, not user-facing:
| Pattern | Reason |
| -------------------------------------------- | -------------- |
| `fix: merge conflicts` | Merge artifact |
| `Merge branch '...' of github.com:...` | Merge artifact |
| `Revert "..."` (when immediately re-applied) | Internal churn |
### 3. Parse work item IDs
Most meaningful commits begin with a Plane work item identifier in brackets:
- `[WEB-XXXX]` — web/frontend product items
- `[SILO-XXXX]` — Silo (integrations: Slack, GitHub, GitLab, Jira/Linear)
- `[MOBILE-XXXX]`, `[API-XXXX]`, etc.
Always preserve these IDs in the release notes — they let readers click through to the source ticket.
### 4. (Optional) Enrich via Plane MCP
For larger features where the commit headline is terse, fetch the work item:
```text
mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874)
```
Use the returned `name` and `description_stripped` to flesh out the bullet. Skip this for routine fixes — commit body is usually enough. Don't enrich every item (slow + work item descriptions are often empty).
### 5. Categorize by conventional-commit type
| Commit prefix | Section |
| -------------------------------- | ------------------- |
| `feat:`, `feat(scope):` | ✨ New Features |
| `fix:`, `fix(scope):` | 🐛 Bug Fixes |
| `refactor:` | 🔧 Refactor & Chore |
| `chore:`, `chore(scope):` | 🔧 Refactor & Chore |
| `chore(deps):`, dependabot bumps | 📦 Dependencies |
### 6. Format
```markdown
# Release vX.Y.Z
## ✨ New Features
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
Optional 12 sentence elaboration drawn from commit body.
## 🐛 Bug Fixes
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
## 🔧 Refactor & Chore
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
## 📦 Dependencies
- Bump `<package>` X.Y.Z → A.B.C (#PR_NUM)
```
Rules:
- Lead with a bold human-readable title (rewrite the commit subject if cryptic)
- Always include the work item ID in brackets and the merge PR number in parens
- Add a sub-line elaboration only when the commit body has substance worth surfacing (acceptance criteria, scope notes, gotchas like "behind feature flag", "requires migration", "requires Vercel setting")
- Drop empty sections
### 7. Update the PR description
```bash
gh pr edit <PR_NUM> --body "$(cat <<'EOF'
<release notes markdown>
EOF
)"
```
Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved.
## Quick Reference: end-to-end
```bash
PR=2498
gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt
# read /tmp/commits.txt, filter, categorize, draft notes
gh pr edit $PR --body "$(cat <<'EOF'
... release notes ...
EOF
)"
```
## Common Mistakes
- **Including `fix: merge conflicts`** — merge artifact, no functional content
- **Dropping the work item ID** — readers rely on `[WEB-XXXX]` to navigate to the ticket
- **Over-enriching with MCP lookups** — work item descriptions are often empty; commit body is usually richer
- **Missing the merge PR number** — always include `(#NNNN)` from the commit subject so reviewers can audit the source PR
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes
- **Editing the title** — release PR titles are version markers; only edit the body
## Plane-Specific Conventions
- Release PRs go from `canary``master`
- PR title format: `release: vX.Y.Z` semver (major.minor.patch)
- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores
+3
View File
@@ -16,6 +16,9 @@ jobs:
contents: read
security-events: write
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: "false"
strategy:
fail-fast: false
matrix:
+8 -1
View File
@@ -1 +1,8 @@
eslint.config.mjs @lifeiscontent
.oxlintrc.json @sriramveeraghanta @lifeiscontent
.oxfmtrc.json @sriramveeraghanta @lifeiscontent
apps/api/ @dheeru0198 @pablohashescobar
apps/web/ @sriramveeraghanta
apps/space/ @sriramveeraghanta
apps/admin/ @sriramveeraghanta
apps/live/ @Palanikannan1437
deployments/ @mguptahub
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Admin UI for Plane",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "API server powering Plane's backend",
"license": "AGPL-3.0"
@@ -1 +0,0 @@
from .workspace import WorkspaceAdminOnlyPermission, WorkspaceAdminWriteMemberReadPermission
@@ -1,54 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework.permissions import BasePermission, SAFE_METHODS
from plane.db.models import WorkspaceMember
Admin = 20
Member = 15
class WorkspaceAdminOnlyPermission(BasePermission):
"""
Permission class for external APIs that restricts access to workspace admins only.
"""
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=Admin,
is_active=True,
).exists()
class WorkspaceAdminWriteMemberReadPermission(BasePermission):
"""
Permission class for external APIs that allows workspace members to read
but restricts write operations to workspace admins only.
"""
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
if request.method in SAFE_METHODS:
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Admin, Member],
is_active=True,
).exists()
return WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=view.workspace_slug,
role=Admin,
is_active=True,
).exists()
+25 -17
View File
@@ -480,44 +480,52 @@ class IssueLinkSerializer(BaseSerializer):
]
class IssueRelationRefSerializer(serializers.Serializer):
"""Project-scoped reference to a related work item."""
project_id = serializers.UUIDField(help_text="Project containing the related work item")
issue_id = serializers.UUIDField(help_text="ID of the related work item")
class IssueRelationResponseSerializer(serializers.Serializer):
"""
Serializer for issue relations response showing grouped relation types.
Returns issue IDs organized by relation type for efficient client-side processing.
Each list contains project_id and issue_id pairs so clients can resolve
cross-project relations.
"""
blocking = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are blocking this issue",
child=IssueRelationRefSerializer(),
help_text="Work items blocking this issue",
)
blocked_by = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that this issue is blocked by",
child=IssueRelationRefSerializer(),
help_text="Work items this issue is blocked by",
)
duplicate = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are duplicates of this issue",
child=IssueRelationRefSerializer(),
help_text="Duplicate work items",
)
relates_to = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that relate to this issue",
child=IssueRelationRefSerializer(),
help_text="Related work items",
)
start_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start after this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that start after this issue",
)
start_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start before this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that start before this issue",
)
finish_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish after this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that finish after this issue",
)
finish_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish before this issue",
child=IssueRelationRefSerializer(),
help_text="Work items that finish before this issue",
)
+4 -3
View File
@@ -17,6 +17,7 @@ from drf_spectacular.utils import OpenApiExample, OpenApiRequest
# Module Imports
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.db.models import FileAsset, User, Workspace
from plane.api.views.base import BaseAPIView
from plane.api.serializers import (
@@ -114,7 +115,7 @@ class UserAssetEndpoint(BaseAPIView):
This endpoint generates the necessary credentials for direct S3 upload.
"""
# get the asset key
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
@@ -287,7 +288,7 @@ class UserServerAssetEndpoint(BaseAPIView):
necessary credentials for direct S3 upload with server-side authentication.
"""
# get the asset key
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
@@ -498,7 +499,7 @@ class GenericAssetEndpoint(BaseAPIView):
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
Supports various file types and includes external source tracking for integrations.
"""
name = request.data.get("name")
name = sanitize_filename(request.data.get("name"))
type = request.data.get("type")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
project_id = request.data.get("project_id")
+98 -40
View File
@@ -23,11 +23,8 @@ from django.db.models import (
Value,
When,
Subquery,
UUIDField,
)
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.utils import timezone
from django.conf import settings
@@ -82,6 +79,7 @@ from plane.db.models import (
Workspace,
)
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
@@ -1861,7 +1859,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
name = request.data.get("name")
name = sanitize_filename(request.data.get("name"))
type = request.data.get("type", False)
size = request.data.get("size")
external_id = request.data.get("external_id")
@@ -2292,14 +2290,35 @@ class IssueRelationListCreateAPIEndpoint(BaseAPIView):
name="Work Item Relations Response",
value={
"blocking": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440000",
},
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440001",
},
],
"blocked_by": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440011",
"issue_id": "550e8400-e29b-41d4-a716-446655440002",
},
],
"blocked_by": ["550e8400-e29b-41d4-a716-446655440002"],
"duplicate": [],
"relates_to": ["550e8400-e29b-41d4-a716-446655440003"],
"relates_to": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440003",
},
],
"start_after": [],
"start_before": ["550e8400-e29b-41d4-a716-446655440004"],
"start_before": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440012",
"issue_id": "550e8400-e29b-41d4-a716-446655440004",
},
],
"finish_after": [],
"finish_before": [],
},
@@ -2316,42 +2335,81 @@ class IssueRelationListCreateAPIEndpoint(BaseAPIView):
Retrieve all relationships for a work item organized by relation type.
Returns a structured response with relations grouped by type.
"""
empty_uuid_array = Value([], output_field=ArrayField(UUIDField()))
def _agg_ids(field, **filter_kwargs):
return Coalesce(
ArrayAgg(field, filter=Q(**filter_kwargs), distinct=True),
empty_uuid_array,
)
issue_relation_qs = IssueRelation.objects.filter(
relations = IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
workspace__slug=slug,
)
relation_ids = issue_relation_qs.aggregate(
blocking_ids=_agg_ids("issue_id", relation_type="blocked_by", related_issue_id=issue_id),
blocked_by_ids=_agg_ids("related_issue_id", relation_type="blocked_by", issue_id=issue_id),
duplicate_ids=_agg_ids("related_issue_id", relation_type="duplicate", issue_id=issue_id),
duplicate_ids_related=_agg_ids("issue_id", relation_type="duplicate", related_issue_id=issue_id),
relates_to_ids=_agg_ids("related_issue_id", relation_type="relates_to", issue_id=issue_id),
relates_to_ids_related=_agg_ids("issue_id", relation_type="relates_to", related_issue_id=issue_id),
start_after_ids=_agg_ids("issue_id", relation_type="start_before", related_issue_id=issue_id),
start_before_ids=_agg_ids("related_issue_id", relation_type="start_before", issue_id=issue_id),
finish_after_ids=_agg_ids("issue_id", relation_type="finish_before", related_issue_id=issue_id),
finish_before_ids=_agg_ids("related_issue_id", relation_type="finish_before", issue_id=issue_id),
).values(
"relation_type",
"issue_id",
"related_issue_id",
issue_project_id=F("issue__project_id"),
related_issue_project_id=F("related_issue__project_id"),
)
response_data = {
"blocking": relation_ids["blocking_ids"],
"blocked_by": relation_ids["blocked_by_ids"],
"duplicate": list(set(relation_ids["duplicate_ids"] + relation_ids["duplicate_ids_related"])),
"relates_to": list(set(relation_ids["relates_to_ids"] + relation_ids["relates_to_ids_related"])),
"start_after": relation_ids["start_after_ids"],
"start_before": relation_ids["start_before_ids"],
"finish_after": relation_ids["finish_after_ids"],
"finish_before": relation_ids["finish_before_ids"],
"blocking": [],
"blocked_by": [],
"duplicate": [],
"relates_to": [],
"start_after": [],
"start_before": [],
"finish_after": [],
"finish_before": [],
}
seen_duplicate = set()
seen_relates_to = set()
for rel in relations:
rt = rel["relation_type"]
if rt == "blocked_by":
if str(rel["related_issue_id"]) == str(issue_id):
response_data["blocking"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
if str(rel["issue_id"]) == str(issue_id):
response_data["blocked_by"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
elif rt == "duplicate":
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_duplicate:
seen_duplicate.add(rel["related_issue_id"])
response_data["duplicate"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_duplicate:
seen_duplicate.add(rel["issue_id"])
response_data["duplicate"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
elif rt == "relates_to":
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_relates_to:
seen_relates_to.add(rel["related_issue_id"])
response_data["relates_to"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_relates_to:
seen_relates_to.add(rel["issue_id"])
response_data["relates_to"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
elif rt == "start_before":
if str(rel["related_issue_id"]) == str(issue_id):
response_data["start_after"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
if str(rel["issue_id"]) == str(issue_id):
response_data["start_before"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
elif rt == "finish_before":
if str(rel["related_issue_id"]) == str(issue_id):
response_data["finish_after"].append(
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
)
if str(rel["issue_id"]) == str(issue_id):
response_data["finish_before"].append(
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
)
return Response(response_data, status=status.HTTP_200_OK)
+30 -54
View File
@@ -3,90 +3,66 @@
# See the LICENSE file for details.
# Python imports
import socket
import ipaddress
import logging
from urllib.parse import urlparse
# Third party imports
from rest_framework import serializers
# Django imports
from django.conf import settings
# Module imports
from .base import DynamicBaseSerializer
from plane.db.models import Webhook, WebhookLog
from plane.db.models.webhook import validate_domain, validate_schema
from plane.utils.ip_address import validate_url
logger = logging.getLogger(__name__)
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def create(self, validated_data):
url = validated_data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
def _validate_webhook_url(self, url):
"""Validate a webhook URL against SSRF and disallowed domain rules."""
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
validate_url(
url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)
except ValueError as e:
logger.warning("Webhook URL validation failed for %s: %s", url, e)
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
hostname = (urlparse(url).hostname or "").rstrip(".").lower()
for addr in ip_addresses:
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 serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
# disallowed-domain check — they're already trusted for SSRF, so
# the loop-back guard would only get in the way of legitimate
# sibling services that share a parent domain with Plane.
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
return
# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
disallowed_domains = ["plane.so"] # Add your disallowed domains here
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
if request:
request_host = request.get_host().split(":")[0] # Remove port if present
request_host = request.get_host().split(":")[0].rstrip(".").lower()
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
def create(self, validated_data):
url = validated_data.get("url", None)
self._validate_webhook_url(url)
return Webhook.objects.create(**validated_data)
def update(self, instance, validated_data):
url = validated_data.get("url", None)
if url:
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
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 serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
disallowed_domains = ["plane.so"] # Add your disallowed domains here
if request:
request_host = request.get_host().split(":")[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
self._validate_webhook_url(url)
return super().update(instance, validated_data)
class Meta:
+21 -6
View File
@@ -18,10 +18,11 @@ from rest_framework.permissions import AllowAny
# Module imports
from ..base import BaseAPIView
from plane.db.models import FileAsset, Workspace, Project, User
from plane.db.models import FileAsset, Workspace, Project, User, WorkspaceMember
from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.utils.path_validator import sanitize_filename
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle
@@ -108,7 +109,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
def post(self, request):
# get the asset key
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", False)
@@ -311,8 +312,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
else:
return
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def post(self, request, slug):
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type")
@@ -376,6 +378,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
@@ -397,6 +400,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def delete(self, request, slug, asset_id):
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
asset.is_deleted = True
@@ -406,6 +410,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
@@ -511,7 +516,7 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id):
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", "")
@@ -752,12 +757,22 @@ class DuplicateAssetEndpoint(BaseAPIView):
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
# Scope the source asset lookup to workspaces the caller is a member of
user_workspace_ids = WorkspaceMember.objects.filter(
member=request.user,
is_active=True,
).values_list("workspace_id", flat=True)
original_asset = FileAsset.objects.filter(
id=asset_id,
is_uploaded=True,
workspace_id__in=user_workspace_ids,
).first()
if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
sanitized_name = sanitize_filename(original_asset.attributes.get("name")) or "unnamed"
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{sanitized_name}"
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
+2 -1
View File
@@ -24,6 +24,7 @@ from plane.db.models import FileAsset, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host
@@ -97,7 +98,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id):
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", False)
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
+100 -69
View File
@@ -7,7 +7,7 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery, Count, IntegerField
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
@@ -22,7 +22,7 @@ from rest_framework import status
from .. import BaseAPIView
from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue, IssueLabel, IssueAssignee, ModuleIssue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
@@ -37,70 +37,97 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id):
sub_issues = (
Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
link_count=Coalesce(
Subquery(
IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.values("issue")
.annotate(count=Count("id"))
.values("count"),
output_field=IntegerField(),
),
0,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
attachment_count=Coalesce(
Subquery(
FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.values("issue_id")
.annotate(count=Count("id"))
.values("count"),
output_field=IntegerField(),
),
0,
)
)
.annotate(
sub_issues_count=Coalesce(
Subquery(
Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.values("parent")
.annotate(count=Count("id"))
.values("count"),
output_field=IntegerField(),
),
0,
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
Subquery(
IssueLabel.objects.filter(issue_id=OuterRef("id"), deleted_at__isnull=True)
.order_by()
.values("issue_id")
.annotate(arr=ArrayAgg("label_id", distinct=True))
.values("arr"),
output_field=ArrayField(UUIDField()),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
Subquery(
IssueAssignee.objects.filter(
issue_id=OuterRef("id"),
assignee__member_project__is_active=True,
deleted_at__isnull=True,
)
.order_by()
.values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
.values("arr"),
output_field=ArrayField(UUIDField()),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
Subquery(
ModuleIssue.objects.filter(
issue_id=OuterRef("id"),
module__archived_at__isnull=True,
deleted_at__isnull=True,
)
.order_by()
.values("issue_id")
.annotate(arr=ArrayAgg("module_id", distinct=True))
.values("arr"),
output_field=ArrayField(UUIDField()),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(state_group=F("state__group"))
.order_by("-created_at")
)
# Ordering
@@ -110,38 +137,42 @@ class SubIssuesEndpoint(BaseAPIView):
if order_by_param:
sub_issues, order_by_param = order_issue_queryset(sub_issues, order_by_param)
sub_issues = list(
sub_issues.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
"state_group",
)
)
# create's a dict with state group name with their respective issue id's
result = defaultdict(list)
for sub_issue in sub_issues:
result[sub_issue.state_group].append(str(sub_issue.id))
result[sub_issue["state_group"]].append(str(sub_issue["id"]))
sub_issues = sub_issues.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
datetime_fields = ["created_at", "updated_at"]
sub_issues = user_timezone_converter(sub_issues, datetime_fields, request.user.user_timezone)
# Grouping
+8
View File
@@ -52,6 +52,7 @@ from plane.db.models import (
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.utils.ip_address import validate_url
from plane.settings.mongo import MongoConnection
@@ -325,6 +326,13 @@ def webhook_send_task(
return
try:
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
validate_url(
webhook.url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)
# Send the webhook event
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
+3
View File
@@ -11,10 +11,13 @@ from django.core.exceptions import ValidationError
from django.db import models
# Module import
from plane.utils.path_validator import sanitize_filename
from .base import BaseModel
def get_upload_path(instance, filename):
filename = sanitize_filename(filename) or uuid4().hex
if instance.workspace_id is not None:
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
return f"user-{uuid4().hex}-{filename}"
+2
View File
@@ -17,6 +17,7 @@ from django import apps
# Module imports
from plane.utils.html_processor import strip_tags
from plane.utils.path_validator import sanitize_filename
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
@@ -376,6 +377,7 @@ class IssueLink(ProjectBaseModel):
def get_upload_path(instance, filename):
filename = sanitize_filename(filename) or uuid4().hex
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
+40
View File
@@ -5,6 +5,8 @@
"""Global Settings"""
# Python imports
import ipaddress
import logging
import os
from urllib.parse import urlparse
from urllib.parse import urljoin
@@ -32,6 +34,44 @@ DEBUG = int(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True
# Webhook IP allowlist — comma-separated IPs or CIDR ranges that are allowed as
# webhook targets even if they resolve to private networks.
# Example: "10.0.0.0/8,192.168.1.0/24,172.16.0.5"
_webhook_allowed_ips_raw = os.environ.get("WEBHOOK_ALLOWED_IPS", "")
WEBHOOK_ALLOWED_IPS = []
_logger = logging.getLogger("plane")
for _cidr in _webhook_allowed_ips_raw.split(","):
_cidr = _cidr.strip()
if not _cidr:
continue
try:
WEBHOOK_ALLOWED_IPS.append(ipaddress.ip_network(_cidr, strict=False))
except ValueError:
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
# Webhook hostname allowlist — comma-separated hostnames that bypass the
# private-IP SSRF check. Useful for trusted internal services whose IPs are
# dynamic in containerised deployments (e.g. docker-compose service DNS,
# kubernetes service hostnames).
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
WEBHOOK_ALLOWED_HOSTS = [
_host.strip().rstrip(".").lower()
for _host in _webhook_allowed_hosts_raw.split(",")
if _host.strip()
]
# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
# these domains or any of their subdomains are rejected (the request host is
# always appended at validation time as a loop-back guard). Empty by default
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
WEBHOOK_DISALLOWED_DOMAINS = [
_d.strip().rstrip(".").lower()
for _d in _webhook_disallowed_domains_raw.split(",")
if _d.strip()
]
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
+2 -1
View File
@@ -18,6 +18,7 @@ from rest_framework.response import Response
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.utils.path_validator import sanitize_filename
# Module imports
from .base import BaseAPIView
@@ -73,7 +74,7 @@ class EntityAssetEndpoint(BaseAPIView):
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
name = request.data.get("name")
name = sanitize_filename(request.data.get("name")) or "unnamed"
type = request.data.get("type", "image/jpeg")
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
entity_type = request.data.get("entity_type", "")
@@ -2,9 +2,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import ipaddress
import pytest
from unittest.mock import patch, MagicMock
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
from plane.utils.ip_address import validate_url
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
@@ -43,6 +46,91 @@ class TestValidateUrlIp:
validate_url_ip("https://example.com") # Should not raise
@pytest.mark.unit
class TestValidateUrlAllowlist:
"""Test validate_url allowlist permits specific private IPs."""
def test_allowlist_permits_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_does_not_permit_other_private_ip(self):
allowed = [ipaddress.ip_network("192.168.1.0/24")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
allowed = [ipaddress.ip_network("127.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
allowed = [
ipaddress.ip_network("2001:db8::/32"),
ipaddress.ip_network("192.168.1.0/24"),
]
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://example.com", allowed_ips=allowed)
def test_allowed_hosts_bypasses_private_ip_check(self):
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
trusted internal services (e.g. Silo) whose IPs are dynamic in
containerised deployments."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
def test_allowed_hosts_matches_case_insensitively(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
validate_url(
"http://Silo.Namespace.Svc.Cluster.Local/x",
allowed_hosts=["silo.namespace.svc.cluster.local"],
) # Should not raise
def test_allowed_hosts_skips_dns_lookup(self):
"""When the hostname is explicitly trusted we shouldn't even resolve it —
protects against operators who allowlist a name that isn't resolvable
from the API container."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
validate_url("http://silo/hook", allowed_hosts=["silo"])
mock_dns.assert_not_called()
def test_allowed_hosts_requires_exact_match(self):
"""Subdomains of an allowed host must NOT bypass — a hostile
``attacker.silo.internal`` should still be blocked when only
``silo.internal`` is allowed."""
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"http://attacker.silo.internal/x",
allowed_hosts=["silo.internal"],
)
def test_allowed_hosts_empty_does_not_bypass(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://silo/hook", allowed_hosts=[])
@pytest.mark.unit
class TestSafeGet:
"""Test safe_get follows redirects safely and blocks SSRF."""
+57
View File
@@ -2,6 +2,63 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import ipaddress
import socket
from urllib.parse import urlparse
def validate_url(url, allowed_ips=None, allowed_hosts=None):
"""
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
Args:
url: The URL to validate.
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
these networks are permitted even if they are private/loopback/reserved.
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
allowed_hosts: Optional iterable of hostnames that bypass IP-based blocking
(exact, case-insensitive match against the URL hostname).
Typically sourced from the WEBHOOK_ALLOWED_HOSTS setting and
used for trusted internal services (e.g. Silo) whose IPs are
dynamic in containerised deployments.
Raises:
ValueError: If the URL is invalid or resolves to a blocked IP.
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: No hostname found")
if parsed.scheme not in ("http", "https"):
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
normalized_host = hostname.rstrip(".").lower()
if allowed_hosts and normalized_host in {
(h or "").rstrip(".").lower() for h in allowed_hosts if h
}:
return
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError("Hostname could not be resolved")
if not addr_info:
raise ValueError("No IP addresses found for the hostname")
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:
if allowed_ips and any(
network.version == ip.version and ip in network for network in allowed_ips
):
continue
raise ValueError("Access to private/internal networks is not allowed")
def get_client_ip(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
+41
View File
@@ -7,9 +7,50 @@ from django.utils.http import url_has_allowed_host_and_scheme
from django.conf import settings
# Python imports
import os
from urllib.parse import urlparse
def sanitize_filename(filename):
"""
Sanitize a filename to prevent path traversal attacks.
Strips directory components, path traversal sequences, and null bytes
from user-supplied filenames used in upload paths and S3 object keys.
Returns None for empty/missing input so callers can still validate
that a filename was provided.
"""
if not filename or not isinstance(filename, str):
return None
# Strip null bytes
filename = filename.replace("\x00", "")
# Normalize backslashes so os.path.basename handles Windows-style paths on POSIX
filename = filename.replace("\\", "/")
# Take only the basename to remove any directory components
filename = os.path.basename(filename)
# Remove any remaining path traversal sequences
filename = filename.replace("..", "")
# Strip whitespace before removing leading dots so " .env" is caught
filename = filename.strip()
# Remove leading dots (hidden files)
filename = filename.lstrip(".")
# Strip any remaining whitespace
filename = filename.strip()
if not filename:
return None
return filename
def _contains_suspicious_patterns(path: str) -> bool:
"""
Check for suspicious patterns that might indicate malicious intent.
+1 -1
View File
@@ -53,7 +53,7 @@ posthog==3.5.0
# crypto
cryptography==46.0.7
# html validator
lxml==6.0.0
lxml==6.1.0
# s3
boto3==1.34.96
# password validator
+1 -1
View File
@@ -1,6 +1,6 @@
-r base.txt
# test framework
pytest==9.0.2
pytest==9.0.3
pytest-django==4.5.2
pytest-cov==4.1.0
pytest-xdist==3.3.1
+1 -1
View File
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN corepack enable
# *****************************************************************************
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "A realtime collaborative server powers Plane's rich text editor",
"license": "AGPL-3.0",
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
ENV TURBO_TELEMETRY_DISABLED=1
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
ENV CI=1
RUN corepack enable pnpm
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
# Setup pnpm package manager with corepack and configure global bin directory for caching
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN corepack enable
# *****************************************************************************
+14 -15
View File
@@ -12,6 +12,7 @@ import type { TOnboardingStep } from "@plane/types";
import { EOnboardingSteps } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useUser } from "@/hooks/store/user";
// local imports
import { SwitchAccountDropdown } from "./switch-account-dropdown";
@@ -26,6 +27,8 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
const { currentStep, updateCurrentStep, hasInvitations } = props;
// store hooks
const { data: user } = useUser();
const { config: instanceConfig } = useInstance();
const isSelfManaged = instanceConfig?.is_self_managed;
// handle step back
const handleStepBack = () => {
@@ -37,7 +40,7 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
updateCurrentStep(EOnboardingSteps.ROLE_SETUP);
break;
case EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN:
updateCurrentStep(EOnboardingSteps.USE_CASE_SETUP);
updateCurrentStep(isSelfManaged ? EOnboardingSteps.PROFILE_SETUP : EOnboardingSteps.USE_CASE_SETUP);
break;
}
};
@@ -45,22 +48,18 @@ export const OnboardingHeader = observer(function OnboardingHeader(props: Onboar
// can go back
const canGoBack = ![EOnboardingSteps.PROFILE_SETUP, EOnboardingSteps.INVITE_MEMBERS].includes(currentStep);
// Get current step number for progress tracking
const getCurrentStepNumber = (): number => {
const stepOrder: TOnboardingStep[] = [
EOnboardingSteps.PROFILE_SETUP,
EOnboardingSteps.ROLE_SETUP,
EOnboardingSteps.USE_CASE_SETUP,
...(hasInvitations
? [EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN]
: [EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN, EOnboardingSteps.INVITE_MEMBERS]),
];
return stepOrder.indexOf(currentStep) + 1;
};
// step order for progress tracking — include INVITE_MEMBERS if user is currently on it
const showInviteStep = !hasInvitations || currentStep === EOnboardingSteps.INVITE_MEMBERS;
const stepOrder: TOnboardingStep[] = [
EOnboardingSteps.PROFILE_SETUP,
...(isSelfManaged ? [] : [EOnboardingSteps.ROLE_SETUP, EOnboardingSteps.USE_CASE_SETUP]),
EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN,
...(showInviteStep ? [EOnboardingSteps.INVITE_MEMBERS] : []),
];
// derived values
const currentStepNumber = getCurrentStepNumber();
const totalSteps = hasInvitations ? 4 : 5; // 4 if invites available, 5 if not
const currentStepNumber = stepOrder.indexOf(currentStep) + 1;
const totalSteps = stepOrder.length;
const userName = user?.display_name
? user?.display_name
: user?.first_name
+12 -2
View File
@@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceMemberInvitation, TOnboardingStep, TOnboardingSteps, TUserProfile } from "@plane/types";
import { EOnboardingSteps } from "@plane/types";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile } from "@/hooks/store/user";
// local components
@@ -27,8 +28,10 @@ export const OnboardingRoot = observer(function OnboardingRoot({ invitations = [
const { data: user } = useUser();
const { data: userProfile, updateUserProfile, finishUserOnboarding } = useUserProfile();
const { workspaces } = useWorkspace();
const { config: instanceConfig } = useInstance();
const workspacesList = Object.values(workspaces ?? {});
const isSelfManaged = instanceConfig?.is_self_managed;
// Calculate total steps based on whether invitations are available
const hasInvitations = invitations.length > 0;
@@ -68,7 +71,14 @@ export const OnboardingRoot = observer(function OnboardingRoot({ invitations = [
(step: EOnboardingSteps, skipInvites?: boolean) => {
switch (step) {
case EOnboardingSteps.PROFILE_SETUP:
setCurrentStep(EOnboardingSteps.ROLE_SETUP);
if (isSelfManaged) {
// Skip role & use case steps for self-hosted
stepChange({ profile_complete: true });
if (workspacesList.length > 0) finishOnboarding();
else setCurrentStep(EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN);
} else {
setCurrentStep(EOnboardingSteps.ROLE_SETUP);
}
break;
case EOnboardingSteps.ROLE_SETUP:
setCurrentStep(EOnboardingSteps.USE_CASE_SETUP);
@@ -91,7 +101,7 @@ export const OnboardingRoot = observer(function OnboardingRoot({ invitations = [
break;
}
},
[stepChange, finishOnboarding, workspacesList]
[stepChange, finishOnboarding, workspacesList, isSelfManaged]
);
const updateCurrentStep = (step: EOnboardingSteps) => setCurrentStep(step);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+9
View File
@@ -51,3 +51,12 @@ API_KEY_RATE_LIMIT=60/minute
# Live Server Secret Key
LIVE_SERVER_SECRET_KEY=htbqvBJAgpm9bzvf3r4urJer0ENReatceh
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
WEBHOOK_ALLOWED_IPS=
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
# SSRF check. Useful for trusted internal services whose container/service IPs are
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
WEBHOOK_ALLOWED_HOSTS=
@@ -58,6 +58,8 @@ x-app-env: &app-env
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
WEBHOOK_ALLOWED_IPS: ${WEBHOOK_ALLOWED_IPS:-}
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
services:
web:
+9
View File
@@ -80,3 +80,12 @@ API_KEY_RATE_LIMIT=60/minute
# Live server environment variables
# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments.
LIVE_SERVER_SECRET_KEY=
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
WEBHOOK_ALLOWED_IPS=
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
# SSRF check. Useful for trusted internal services whose container/service IPs are
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
WEBHOOK_ALLOWED_HOSTS=
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "plane",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Open-source project management that unlocks customer value",
"license": "AGPL-3.0",
@@ -76,7 +76,11 @@
"yaml@1": "1.10.3",
"yaml@2": "2.8.3",
"path-to-regexp": "0.1.13",
"defu": "6.1.5"
"defu": "6.1.5",
"postcss": "8.5.10",
"axios": "catalog:",
"follow-redirects": "1.16.0",
"uuid": "catalog:"
},
"onlyBuiltDependencies": [
"@parcel/watcher",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/codemods",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"scripts": {
"test": "vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Core Editor that powers Plane",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "React hooks that are shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "I18n shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Logger shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Shared state shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "common tailwind configuration across monorepo",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/ui",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "UI components shared across multiple apps internally",
"license": "AGPL-3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
+96 -110
View File
@@ -45,9 +45,6 @@ catalogs:
'@types/react-dom':
specifier: 18.3.1
version: 18.3.1
axios:
specifier: 1.15.0
version: 1.15.0
dotenv:
specifier: 16.4.7
version: 16.4.7
@@ -78,9 +75,6 @@ catalogs:
tsdown:
specifier: 0.16.0
version: 0.16.0
uuid:
specifier: 13.0.0
version: 13.0.0
overrides:
express: 4.22.0
@@ -120,6 +114,10 @@ overrides:
yaml@2: 2.8.3
path-to-regexp: 0.1.13
defu: 6.1.5
postcss: 8.5.10
axios: 1.15.2
follow-redirects: 1.16.0
uuid: 14.0.0
importers:
@@ -189,8 +187,8 @@ importers:
specifier: ^3.13.12
version: 3.13.12
axios:
specifier: 'catalog:'
version: 1.15.0
specifier: 1.15.2
version: 1.15.2
isbot:
specifier: ^5.1.31
version: 5.1.31
@@ -228,8 +226,8 @@ importers:
specifier: 'catalog:'
version: 2.2.4(react@18.3.1)
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
devDependencies:
'@plane/tailwind-config':
specifier: workspace:*
@@ -316,8 +314,8 @@ importers:
specifier: 'catalog:'
version: 2.26.2(@tiptap/core@2.26.3(@tiptap/pm@3.6.6))(@tiptap/pm@3.6.6)
axios:
specifier: 'catalog:'
version: 1.15.0
specifier: 1.15.2
version: 1.15.2
compression:
specifier: 1.8.1
version: 1.8.1
@@ -349,8 +347,8 @@ importers:
specifier: ^0.34.3
version: 0.34.3
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
ws:
specifier: ^8.18.3
version: 8.18.3
@@ -461,8 +459,8 @@ importers:
specifier: 'catalog:'
version: 7.13.1(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)
axios:
specifier: 'catalog:'
version: 1.15.0
specifier: 1.15.2
version: 1.15.2
clsx:
specifier: ^2.0.0
version: 2.1.1
@@ -512,8 +510,8 @@ importers:
specifier: 'catalog:'
version: 2.2.4(react@18.3.1)
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
devDependencies:
'@plane/tailwind-config':
specifier: workspace:*
@@ -621,8 +619,8 @@ importers:
specifier: ^8.21.3
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
axios:
specifier: 'catalog:'
version: 1.15.0
specifier: 1.15.2
version: 1.15.2
clsx:
specifier: ^2.0.0
version: 2.1.1
@@ -714,8 +712,8 @@ importers:
specifier: ^1.2.2
version: 1.3.0(react@18.3.1)
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
devDependencies:
'@plane/tailwind-config':
specifier: workspace:*
@@ -962,8 +960,8 @@ importers:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.26.3(@tiptap/pm@2.26.1))
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
y-indexeddb:
specifier: ^9.0.12
version: 9.0.12(yjs@13.6.27)
@@ -996,8 +994,8 @@ importers:
specifier: 'catalog:'
version: 18.3.1
postcss:
specifier: ^8.4.38
version: 8.5.6
specifier: 8.5.10
version: 8.5.10
tsdown:
specifier: 'catalog:'
version: 0.16.0(typescript@5.8.3)(unrun@0.2.34)
@@ -1189,8 +1187,8 @@ importers:
specifier: workspace:*
version: link:../types
axios:
specifier: 'catalog:'
version: 1.15.0
specifier: 1.15.2
version: 1.15.2
file-type:
specifier: ^21.3.1
version: 21.3.3
@@ -1226,8 +1224,8 @@ importers:
specifier: 'catalog:'
version: 6.0.8(mobx@6.12.0)
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
zod:
specifier: ^3.22.2
version: 3.25.76
@@ -1254,8 +1252,8 @@ importers:
specifier: 4.1.17
version: 4.1.17
postcss:
specifier: 8.5.6
version: 8.5.6
specifier: 8.5.10
version: 8.5.10
devDependencies:
tailwindcss:
specifier: 4.1.17
@@ -1416,13 +1414,13 @@ importers:
version: 18.3.1
autoprefixer:
specifier: ^10.4.19
version: 10.4.21(postcss@8.5.6)
version: 10.4.21(postcss@8.5.10)
postcss-cli:
specifier: ^11.0.0
version: 11.0.1(jiti@2.6.1)(postcss@8.5.6)
version: 11.0.1(jiti@2.6.1)(postcss@8.5.10)
postcss-nested:
specifier: ^6.0.1
version: 6.2.0(postcss@8.5.6)
version: 6.2.0(postcss@8.5.10)
storybook:
specifier: 9.1.19
version: 9.1.19(@testing-library/dom@10.4.0)(prettier@3.7.4)(vite@7.3.2(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.3))
@@ -1490,8 +1488,8 @@ importers:
specifier: ^11.0.5
version: 11.0.5
uuid:
specifier: 'catalog:'
version: 13.0.0
specifier: 14.0.0
version: 14.0.0
devDependencies:
'@plane/typescript-config':
specifier: workspace:*
@@ -4734,14 +4732,14 @@ packages:
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.15.0:
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
axios@1.15.2:
resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
babel-dead-code-elimination@1.0.10:
resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==}
@@ -5722,8 +5720,8 @@ packages:
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -6044,7 +6042,7 @@ packages:
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -7225,14 +7223,14 @@ packages:
engines: {node: '>=18'}
hasBin: true
peerDependencies:
postcss: ^8.0.0
postcss: 8.5.10
postcss-load-config@5.1.0:
resolution: {integrity: sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
postcss: 8.5.10
tsx: ^4.8.1
peerDependenciesMeta:
jiti:
@@ -7246,37 +7244,37 @@ packages:
resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
postcss-modules-local-by-default@4.2.0:
resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
postcss-modules-scope@3.2.1:
resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
postcss-modules-values@4.0.0:
resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
postcss-nested@6.2.0:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.2.14
postcss: 8.5.10
postcss-reporter@7.1.0:
resolution: {integrity: sha512-/eoEylGWyy6/DOiMP5lmFRdmDKThqgn7D6hP2dXKJI/0rJSO1ADFNngZfDzxL0YAxFvws+Rtpuji1YIHj4mySA==}
engines: {node: '>=10'}
peerDependencies:
postcss: ^8.1.0
postcss: 8.5.10
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
@@ -7293,8 +7291,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
postcss@8.5.10:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.7.4:
@@ -8431,16 +8429,8 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
uvu@0.5.6:
@@ -9195,7 +9185,7 @@ snapshots:
dependencies:
'@effect/platform': 0.94.1(effect@3.20.0)
effect: 3.20.0
uuid: 11.1.0
uuid: 14.0.0
optionalDependencies:
ioredis: 5.7.0
@@ -9246,7 +9236,7 @@ snapshots:
'@effect/experimental': 0.58.0(@effect/platform@0.94.1(effect@3.20.0))(effect@3.20.0)(ioredis@5.7.0)
'@effect/platform': 0.94.1(effect@3.20.0)
effect: 3.20.0
uuid: 11.1.0
uuid: 14.0.0
'@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.20.0))(effect@3.20.0)(ioredis@5.7.0))(@effect/platform@0.94.1(effect@3.20.0))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)':
dependencies:
@@ -9459,7 +9449,7 @@ snapshots:
kleur: 4.1.5
lodash.debounce: 4.0.8
redlock: 4.2.0
uuid: 11.1.0
uuid: 14.0.0
y-protocols: 1.0.6(yjs@13.6.27)
yjs: 13.6.27
transitivePeerDependencies:
@@ -9485,7 +9475,7 @@ snapshots:
async-lock: 1.4.1
kleur: 4.1.5
lib0: 0.2.114
uuid: 11.1.0
uuid: 14.0.0
ws: 8.18.3
y-protocols: 1.0.6(yjs@13.6.27)
yjs: 13.6.27
@@ -10571,7 +10561,7 @@ snapshots:
dequal: 2.0.3
polished: 4.3.1
storybook: 9.1.19(@testing-library/dom@10.4.0)(prettier@3.7.4)(vite@7.3.2(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.3))
uuid: 9.0.1
uuid: 14.0.0
'@storybook/addon-backgrounds@8.6.14(storybook@9.1.19(@testing-library/dom@10.4.0)(prettier@3.7.4)(vite@7.3.2(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.3)))':
dependencies:
@@ -11050,7 +11040,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.17
'@tailwindcss/oxide': 4.1.17
postcss: 8.5.6
postcss: 8.5.10
tailwindcss: 4.1.17
'@tailwindcss/typography@0.5.19':
@@ -11970,23 +11960,23 @@ snapshots:
attr-accept@2.2.5: {}
autoprefixer@10.4.21(postcss@8.5.6):
autoprefixer@10.4.21(postcss@8.5.10):
dependencies:
browserslist: 4.28.1
caniuse-lite: 1.0.30001759
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
postcss: 8.5.6
postcss: 8.5.10
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
axios@1.15.0:
axios@1.15.2:
dependencies:
follow-redirects: 1.15.11
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -12411,12 +12401,12 @@ snapshots:
css-loader@6.11.0(webpack@5.104.1(@swc/core@1.13.5(@swc/helpers@0.5.17))):
dependencies:
icss-utils: 5.1.0(postcss@8.5.6)
postcss: 8.5.6
postcss-modules-extract-imports: 3.1.0(postcss@8.5.6)
postcss-modules-local-by-default: 4.2.0(postcss@8.5.6)
postcss-modules-scope: 3.2.1(postcss@8.5.6)
postcss-modules-values: 4.0.0(postcss@8.5.6)
icss-utils: 5.1.0(postcss@8.5.10)
postcss: 8.5.10
postcss-modules-extract-imports: 3.1.0(postcss@8.5.10)
postcss-modules-local-by-default: 4.2.0(postcss@8.5.10)
postcss-modules-scope: 3.2.1(postcss@8.5.10)
postcss-modules-values: 4.0.0(postcss@8.5.10)
postcss-value-parser: 4.2.0
semver: 7.7.4
optionalDependencies:
@@ -12991,7 +12981,7 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.11: {}
follow-redirects@1.16.0: {}
fontfaceobserver@2.1.0: {}
@@ -13375,9 +13365,9 @@ snapshots:
safer-buffer: 2.1.2
optional: true
icss-utils@5.1.0(postcss@8.5.6):
icss-utils@5.1.0(postcss@8.5.10):
dependencies:
postcss: 8.5.6
postcss: 8.5.10
ieee754@1.2.1: {}
@@ -14775,15 +14765,15 @@ snapshots:
possible-typed-array-names@1.1.0: {}
postcss-cli@11.0.1(jiti@2.6.1)(postcss@8.5.6):
postcss-cli@11.0.1(jiti@2.6.1)(postcss@8.5.10):
dependencies:
chokidar: 3.6.0
dependency-graph: 1.0.0
fs-extra: 11.3.1
picocolors: 1.1.1
postcss: 8.5.6
postcss-load-config: 5.1.0(jiti@2.6.1)(postcss@8.5.6)
postcss-reporter: 7.1.0(postcss@8.5.6)
postcss: 8.5.10
postcss-load-config: 5.1.0(jiti@2.6.1)(postcss@8.5.10)
postcss-reporter: 7.1.0(postcss@8.5.10)
pretty-hrtime: 1.0.3
read-cache: 1.0.0
slash: 5.1.0
@@ -14793,44 +14783,44 @@ snapshots:
- jiti
- tsx
postcss-load-config@5.1.0(jiti@2.6.1)(postcss@8.5.6):
postcss-load-config@5.1.0(jiti@2.6.1)(postcss@8.5.10):
dependencies:
lilconfig: 3.1.3
yaml: 2.8.3
optionalDependencies:
jiti: 2.6.1
postcss: 8.5.6
postcss: 8.5.10
postcss-modules-extract-imports@3.1.0(postcss@8.5.6):
postcss-modules-extract-imports@3.1.0(postcss@8.5.10):
dependencies:
postcss: 8.5.6
postcss: 8.5.10
postcss-modules-local-by-default@4.2.0(postcss@8.5.6):
postcss-modules-local-by-default@4.2.0(postcss@8.5.10):
dependencies:
icss-utils: 5.1.0(postcss@8.5.6)
postcss: 8.5.6
icss-utils: 5.1.0(postcss@8.5.10)
postcss: 8.5.10
postcss-selector-parser: 7.1.0
postcss-value-parser: 4.2.0
postcss-modules-scope@3.2.1(postcss@8.5.6):
postcss-modules-scope@3.2.1(postcss@8.5.10):
dependencies:
postcss: 8.5.6
postcss: 8.5.10
postcss-selector-parser: 7.1.0
postcss-modules-values@4.0.0(postcss@8.5.6):
postcss-modules-values@4.0.0(postcss@8.5.10):
dependencies:
icss-utils: 5.1.0(postcss@8.5.6)
postcss: 8.5.6
icss-utils: 5.1.0(postcss@8.5.10)
postcss: 8.5.10
postcss-nested@6.2.0(postcss@8.5.6):
postcss-nested@6.2.0(postcss@8.5.10):
dependencies:
postcss: 8.5.6
postcss: 8.5.10
postcss-selector-parser: 6.1.2
postcss-reporter@7.1.0(postcss@8.5.6):
postcss-reporter@7.1.0(postcss@8.5.10):
dependencies:
picocolors: 1.1.1
postcss: 8.5.6
postcss: 8.5.10
thenby: 1.3.4
postcss-selector-parser@6.0.10:
@@ -14850,7 +14840,7 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.6:
postcss@8.5.10:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
@@ -15536,7 +15526,7 @@ snapshots:
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.6
postcss: 8.5.10
saxes@6.0.0:
dependencies:
@@ -16250,11 +16240,7 @@ snapshots:
utils-merge@1.0.1: {}
uuid@11.1.0: {}
uuid@13.0.0: {}
uuid@9.0.1: {}
uuid@14.0.0: {}
uvu@0.5.6:
dependencies:
@@ -16356,7 +16342,7 @@ snapshots:
esbuild: 0.25.0
fdir: 6.5.0(picomatch@2.3.2)
picomatch: 2.3.2
postcss: 8.5.6
postcss: 8.5.10
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
+2 -2
View File
@@ -19,7 +19,7 @@ catalog:
"@types/node": 22.12.0
"@types/react-dom": 18.3.1
"@types/react": 18.3.11
axios: 1.15.0
axios: 1.15.2
express: 4.22.0
lodash-es: 4.18.0
lucide-react: 0.469.0
@@ -32,7 +32,7 @@ catalog:
swr: 2.2.4
tsdown: 0.16.0
typescript: 5.8.3
uuid: 13.0.0
uuid: 14.0.0
vite: 7.3.2
onlyBuiltDependencies: