Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a4adc55b | ||
|
|
1dabc632bf | ||
|
|
761c999e0c | ||
|
|
32fb88ab24 | ||
|
|
03a2be84b7 | ||
|
|
c62930ebcf | ||
|
|
f1d567accc | ||
|
|
62b2d1b207 | ||
|
|
da41f14a05 | ||
|
|
aea66f53f4 | ||
|
|
45b4fc8932 | ||
|
|
a8a16c8ba0 | ||
|
|
ac11c3ef79 | ||
|
|
13db2f883f | ||
|
|
bbf14fba31 | ||
|
|
db3c8f27dc | ||
|
|
cf696d200d |
@@ -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
|
||||
@@ -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 1–2 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
|
||||
@@ -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
@@ -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
|
||||
@@ -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,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,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()
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
|
||||
# *****************************************************************************
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/codemods",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/services",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Generated
+96
-110
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user