Compare commits

..

82 Commits

Author SHA1 Message Date
sriram veeraghanta d0a4adc55b release: v1.3.1 #8917 2026-05-15 01:39:46 +05:30
sriram veeraghanta 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 veeraghanta 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 veeraghanta 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] 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 veeraghanta 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 veeraghanta 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
sriram veeraghanta 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 veeraghanta 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 Kumar 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 veeraghanta 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 veeraghanta 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ương 13db2f883f enhance sub-issue query performance with optimized annotations and subqueries (#8889) 2026-04-14 13:54:28 +05:30
dependabot[bot] 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 Bhatia db3c8f27dc [WEB-6840] feat: skip role & use-case steps for self-hosted instances (#8890) 2026-04-13 18:24:12 +05:30
sriram veeraghanta 39325d28a6 chore: update dependencies (Django, cryptography, axios, lodash) (#8880)
* chore: update dependencies (Django, cryptography, axios, lodash)

- Django 4.2.29 → 4.2.30
- cryptography 46.0.6 → 46.0.7
- axios 1.13.5 → 1.15.0
- lodash 4.17.23 → 4.18.0

* chore: update lodash from 4.18.0 to 4.18.1
2026-04-10 01:13:02 +05:30
sriram veeraghanta c21d2c6fb3 chore: remove Intercom integration and chat support components (#8875)
Intercom is no longer used. This removes all related frontend components,
hooks, custom events, API config, types, and i18n keys.
2026-04-10 00:16:45 +05:30
b-saikrishnakanth e6b9d4c9ba [WEB-6785] fix: update border for project timezone (#8870) 2026-04-09 21:30:48 +05:30
b-saikrishnakanth 6023e8cfc8 [WEB-6784] feat scrollbar in shortcuts modal (#8872)
* fix: update border for project timezone

* feat: added scrollbar in keyboard shortcuts modal

* fix: remove unnecessary changes

* fix: remove redundant overflow
2026-04-09 21:30:15 +05:30
okxint 77c4b9c774 fix: strip whitespace and handle null values in instance configuration (#8744)
When patching instance configuration values, the raw values from
request.data were used directly without sanitization. This adds:
- Whitespace stripping via str().strip() to prevent leading/trailing
  spaces from being stored
- Explicit None handling so that null values become empty strings
  instead of the literal string "None"
2026-04-08 16:06:52 +05:30
sriram veeraghanta 8a2579ce9b fix: prevent ORM field injection via segment parameter in analytics (GHSA-93x3-ghh7-72j3) (#8864)
* fix: prevent ORM field injection via segment parameter in analytics (GHSA-93x3-ghh7-72j3)

Centralize analytics field allowlists into VALID_ANALYTICS_FIELDS and
VALID_YAXIS constants in analytics_plot.py. Add defense-in-depth
validation in build_graph_plot() and extract_axis() so no caller can
pass arbitrary field references to Django F() expressions. Add missing
segment validation to SavedAnalyticEndpoint. Also fixes ExportAnalytics
using "estimate_point" instead of "estimate_point__value".

* fix: address PR review - remove unused imports and validate stored query params

Remove unused VALID_ANALYTICS_FIELDS and VALID_YAXIS imports from
analytic_plot_export.py. Add x_axis/y_axis allowlist validation in
SavedAnalyticEndpoint for stored query_dict values to prevent 500
errors from malformed saved analytics.
2026-04-07 16:04:48 +05:30
Niels Kaspers 7c2fc2dd7f fix: update Twitter icon and links to X (#8785) (#8790) 2026-04-07 15:34:54 +05:30
dependabot[bot] d1db13c3a7 chore(deps): bump vite in the npm_and_yarn group across 1 directory (#8863)
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 13:18:46 +05:30
sriram veeraghanta cf696d200d release: v1.3.0 #8835 2026-04-06 20:00:08 +05:30
sriram veeraghanta bb128e3e16 chore: upgrade turbo from v2.8.12 to v2.9.4 (#8859) 2026-04-06 16:04:57 +05:30
sriram veeraghanta 63fac3b8c4 fix: validate redirects in favicon fetching to prevent SSRF (#8858)
* fix: validate redirects in favicon fetching to prevent SSRF

The previous SSRF fix (GHSA-jcc6-f9v6-f7jw) only validated redirects for
the main page URL but not for the favicon fetch path. An attacker could
craft an HTML page with a favicon link that redirects to a private IP,
bypassing the IP validation and leaking internal network data as base64.

Extract a reusable `safe_get()` function that validates every redirect hop
against private/internal IPs and use it for both page and favicon fetches.

Resolves: GHSA-9fr2-pprw-pp9j

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback for SSRF favicon fix

- Fix off-by-one in redirect limit: only raise RuntimeError when the
  response is still a redirect after MAX_REDIRECTS hops, not when the
  final response is a successful 200
- Return final URL from safe_get() so favicon href resolution uses the
  correct origin after redirects instead of the original URL
- Add unit tests for validate_url_ip and safe_get covering private IP
  blocking, redirect-following, and redirect limit enforcement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:04:43 +05:30
sriram veeraghanta 587fe76032 fix: prevent privilege escalation in project member role updates (GHSA-494h-3rcq-5g3c) (#8833)
Restrict role modification in ProjectMemberViewSet.partial_update to
Admins only and enforce that requesters cannot modify or assign roles
equal to or higher than their own. Previously, Guests could demote
Admins by exploiting a missing lower-bound check on role changes.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:54:01 +05:30
Anmol Singh Bhatia a18d90da86 [WEB-6813] fix: module not associated when accepting intake work items (#8839)
* fix: intake module association on accept

* chore: code refactoring
2026-03-31 23:39:34 +05:30
Akshat Jain febf98ea54 [INFRA-351] fix: correct directory and command for space program in supervisor.conf #8838 2026-03-31 18:53:51 +05:30
sriramveeraghanta 5747dc6fd8 chore: Intake snooze modal width 2026-03-31 18:26:41 +05:30
Akshat Jain d83944cc8d [INFRA-346] chore: remove artifacts.plane.so references from community deployments (#8836) 2026-03-31 17:56:32 +05:30
sriramveeraghanta 799b9cbfc5 chore: adding traget commit sha for the github release 2026-03-31 17:54:47 +05:30
sriram veeraghanta a01b51fca5 fix: scope IssueBulkUpdateDateEndpoint query to workspace and project (#8834)
The bulk update date endpoint fetched issues by ID without filtering
by workspace or project, allowing any authenticated project member to
modify start_date and target_date of issues in any workspace/project
across the entire instance (IDOR - CWE-639).

Scoped the query to include workspace__slug and project_id filters,
consistent with other issue endpoints in the codebase.

Ref: GHSA-4q54-h4x9-m329
2026-03-31 17:43:35 +05:30
sriramveeraghanta 00a51f5e6a chore: version bump 2026-03-31 17:09:35 +05:30
sriram veeraghanta b73d6344ad chore(deps): replace dotenvx with dotenv and update overrides (#8832)
* chore(deps): replace dotenvx with dotenv and update dependency overrides

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: sort devDependencies in package.json files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:55:17 +05:30
sriram veeraghanta f0ec84661d chore(deps): update dependency overrides (#8831)
Update brace-expansion override from 2.0.2 to 5.0.5 and add picomatch,
yaml@1, and yaml@2 overrides to pin transitive dependency versions.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:32:31 +05:30
Anmol Singh Bhatia d8ed19f204 [WEB-6794] fix: align profile cover update with correct unsplash and upload handling (#8830)
* fix: profile cover update

* chore: code refactoring

* chore: code refactoring
2026-03-31 15:54:12 +05:30
Saurabh Kumar 9fa707b260 [SILO-1026] feat: add estimates external API endpoints (#8664)
* add project summary endpoint

* update response structure

* add estimates external API endpoints with migrations

* fix invalid project and workspace error
2026-03-30 15:30:02 +05:30
Saurabh Kumar d7c80885fd [SILO-1087] feat: add IssueRelations external API (#8763)
* add IssueRelations external API

* update serializer methods and filter by slug
2026-03-30 15:29:16 +05:30
dependabot[bot] 9851fe0b8f chore(deps): bump cryptography (#8819)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  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-03-30 12:28:39 +05:30
Anmol Singh Bhatia 5e237938ff [WEB-6783] fix: crash when deleting work item from peek view in workspace spreadsheet (#8821)
* fix: guard against undefined issue in SpreadsheetIssueRow

* fix: add defensive guard for isIssueNew in list block-root
2026-03-30 12:20:39 +05:30
b-saikrishnakanth f0468a9173 [WEB-6763] fix: date range dropdown clipped in sub-issues list #8809 2026-03-27 16:01:24 +05:30
b-saikrishnakanth c53968a7f8 [WEB-6762] fix: missing profile icons for recent activities on "Your Work" Page #8812 2026-03-27 16:00:51 +05:30
Aaron 97b4abd693 fix: tsdown watch (#8813)
closes #8791
2026-03-27 15:59:55 +05:30
dependabot[bot] 130ba5ee6c chore(deps): bump requests (#8804)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [requests](https://github.com/psf/requests).


Updates `requests` from 2.32.4 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.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-03-27 00:11:02 +05:30
M. Palanikannan 113bba46ea fix: migrate page navigation pane tabs from headless ui to propel (#8805) 2026-03-26 20:43:03 +05:30
b-saikrishnakanth ce401c723e [WEB-6734] fix: circular progress indicator stroke color#8802 2026-03-26 18:13:57 +05:30
b-saikrishnakanth 5396d438a3 Open [WEB-6739] fix: color inside of active projects of analytics overview tab #8803 2026-03-26 18:13:30 +05:30
Anmol Singh Bhatia 942d2b98ef [WEB-6702] feat: redesign intake action buttons and use design tokens (#8801)
* feat: intake action buttons redesign

* chore: code refactoring
2026-03-26 18:12:24 +05:30
ouchan d94a269451 fix: add model_activity.delay() to API issue update/create paths for webhook dispatch (#8792)
Fixes #6746

API-driven issue updates (PUT update, PUT create-via-upsert, PATCH) were
missing `model_activity.delay()` calls, so webhooks were never dispatched
for changes made through the API. The web UI paths already include these
calls (e.g. in `post()` at L475), but the `put()` and `partial_update()`
methods only called `issue_activity.delay()`.

This adds `model_activity.delay()` immediately after each existing
`issue_activity.delay()` in these three code paths, using the same
signature as the existing call in `post()`.

Tested on Plane CE v1.2.1 self-hosted: API PATCH triggers
`webhook_send_task` in the Celery worker, confirming webhook delivery.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:31:16 +05:30
sriramveeraghanta 54b80e91eb fix: broken lockfile 2026-03-25 13:23:36 +05:30
sriramveeraghanta 6e033f9fdb sync: master branch changes to preview 2026-03-25 13:21:43 +05:30
sriram veeraghanta f3c7c057b4 chore: remove service token endpoint which is unused (#8797) 2026-03-25 13:13:58 +05:30
sriram veeraghanta d91b5a274b fix: removed unused files 2026-03-25 02:04:20 +05:30
darkingtail 5a7d1ebd65 fix: remove unused imports and variables (part 3) (#8753)
Resolve oxlint no-unused-vars warnings in
apps/web/core/components/issues/.
2026-03-25 02:04:20 +05:30
darkingtail 04d4490293 fix: remove unused imports and variables (part 2 — web/core non-issues) (#8752)
* fix: remove unused imports and variables (part 2)

Resolve oxlint no-unused-vars warnings in apps/web/core/
(excluding components/issues/).

* fix: resolve CI check failures

* fix: resolve check:types failures
2026-03-25 02:04:20 +05:30
darkingtail d9695afcdc fix: remove unused imports and variables (part 1 — packages & non-web-core) (#8751)
* fix: remove unused imports and variables (part 1)

Resolve oxlint no-unused-vars warnings in packages/*, apps/admin,
apps/space, apps/live, and apps/web (non-core).

* fix: resolve CI check failures

* fix: resolve check:types failures

* fix: resolve check:types and check:format failures

- Use destructuring alias for activeCycleResolvedPath
- Format propel tab-navigation file

* fix: format propel button helper with oxfmt

Reorder Tailwind classes to match oxfmt canonical ordering.
2026-03-25 02:04:20 +05:30
sriram veeraghanta c3c7c72aff fix: package updates 2026-03-25 00:22:25 +05:30
Bavisetti Narayan 9d3b5d9da7 fix: added workspace member check in allow permission for creator #8778 2026-03-24 00:44:50 +05:30
sriram veeraghanta 1faf06c755 chore: remove chat support component 2026-03-18 00:13:13 +05:30
dependabot[bot] 72b6453f6f chore(deps): bump the actions group across 1 directory with 11 updates (#8741)
Bumps the actions group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [makeplane/actions](https://github.com/makeplane/actions) | `1.0.0` | `1.4.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2.1.0` | `2.5.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `6` |
| [actions/setup-go](https://github.com/actions/setup-go) | `5` | `6` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.9.0` | `7.0.0` |
| [tailscale/github-action](https://github.com/tailscale/github-action) | `2` | `4` |
| [actions/cache](https://github.com/actions/cache) | `4` | `5` |



Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `makeplane/actions` from 1.0.0 to 1.4.0
- [Release notes](https://github.com/makeplane/actions/releases)
- [Commits](https://github.com/makeplane/actions/compare/v1.0.0...v1.4.0)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `softprops/action-gh-release` from 2.1.0 to 2.5.0
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2.1.0...v2.5.0)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `actions/setup-go` from 5 to 6
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/build-push-action` from 6.9.0 to 7.0.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.9.0...v7.0.0)

Updates `tailscale/github-action` from 2 to 4
- [Release notes](https://github.com/tailscale/github-action/releases)
- [Commits](https://github.com/tailscale/github-action/compare/v2...v4)

Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: makeplane/actions
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: tailscale/github-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 00:09:47 +05:30
Vipin Chaudhary 428cb478b1 [WEB-6610] Fix work item drag handle hover gap (#8759)
* [WEB-6610] Fix work item drag handle hover gap

Amp-Thread-ID: https://ampcode.com/threads/T-019ce703-e30e-769b-9436-a7f5506e8a6c
Co-authored-by: Amp <amp@ampcode.com>

* fix: use p-0! pl-6! for correct drag handle hover area

Amp-Thread-ID: https://ampcode.com/threads/T-019ce703-e30e-769b-9436-a7f5506e8a6c
Co-authored-by: Amp <amp@ampcode.com>

* fix: update containerClassName to -ml-6 border-none p-0! pl-6!

Amp-Thread-ID: https://ampcode.com/threads/T-019ce703-e30e-769b-9436-a7f5506e8a6c
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 00:07:52 +05:30
sriram veeraghanta e972989522 chore(deps): upgrade the undici and flatted versions 2026-03-18 00:05:21 +05:30
Anmol Singh Bhatia 588dc2927e [WEB-6599] feat: instance not ready ui revamp (#8755)
* feat: instance not ready ui revamp

* chore: code refactoring

* chore: code refactoring
2026-03-16 14:29:33 +05:30
dependabot[bot] 6627282bc5 chore(deps): bump pytest from 7.4.0 to 9.0.2 in /apps/api (#8693)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.0 to 9.0.2.
- [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/7.4.0...9.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 19:24:21 +05:30
dependabot[bot] d7c12f9730 chore(deps): bump python-json-logger from 3.3.0 to 4.0.0 in /apps/api (#8692)
Bumps [python-json-logger](https://github.com/nhairs/python-json-logger) from 3.3.0 to 4.0.0.
- [Release notes](https://github.com/nhairs/python-json-logger/releases)
- [Changelog](https://github.com/nhairs/python-json-logger/blob/main/docs/changelog.md)
- [Commits](https://github.com/nhairs/python-json-logger/compare/v3.3.0...v4.0.0)

---
updated-dependencies:
- dependency-name: python-json-logger
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-07 19:22:30 +05:30
sriram veeraghanta 7902805635 release: v1.2.3 #8717 2026-03-05 18:15:16 +05:30
sriram veeraghanta 7b1f5a47f5 [SECUR-116] fix: ssrf webhook url for ip address #8716 2026-03-05 17:28:32 +05:30
sriramveeraghanta 9a7696acac chore: version upgrade 2026-03-05 17:25:22 +05:30
sriram veeraghanta 2a978e3ac0 release: v1.2.2 #8645 2026-02-23 14:14:21 +05:30
sriram veeraghanta 8c23fdd1d8 fix: Member Information Disclosure via Public Endpoint #8646 2026-02-20 18:34:56 +05:30
sriram veeraghanta a77af4e67e Update apps/api/plane/app/views/issue/attachment.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 18:33:09 +05:30
Sangeetha b783f25bfa [SECUR-113] fix: ssrf for work item links (#8607) 2026-02-20 18:29:42 +05:30
sriramveeraghanta 95d121ce38 chore(deps): upgrade django version 2026-02-20 18:27:13 +05:30
Sangeetha 318c993082 [SECUR-104] fix: Arbitrary Modification of API Token Rate Limits#8612 2026-02-20 18:27:13 +05:30
dependabot[bot] 6c984e18ae chore(deps): bump cryptography (#8625)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 44.0.1 to 46.0.5
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/44.0.1...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  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-02-20 18:27:13 +05:30
sriramveeraghanta ec44b63027 chore: pacakge version 2026-02-20 18:05:15 +05:30
sriram veeraghanta 1548288e95 fix: IDOR Vulnerabilities in Asset & Attachment Endpoints (#8644)
* fix: idor issues in project assets and issue attachements

* fix: comments
2026-02-20 18:03:57 +05:30
sriram veeraghanta 81cea3256a release: v1.2.1 #8322 2025-12-12 16:36:50 +05:30
sriramveeraghanta 07f269e7f3 chore: version bump 2025-12-12 15:09:53 +05:30
sriramveeraghanta ce69644d53 chore(deps): upgrade next themes package 2025-12-12 13:43:47 +05:30
463 changed files with 4119 additions and 3030 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
-7
View File
@@ -1,7 +0,0 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
check-hidden = true
# ignore all CamelCase and camelCase
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
ignore-words-list = tread
-55
View File
@@ -1,55 +0,0 @@
version: 2
updates:
# JavaScript/TypeScript dependencies (pnpm monorepo root)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "javascript"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# Python dependencies
- package-ecosystem: "pip"
directory: "/apps/api"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "python"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"
groups:
actions:
patterns:
- "*"
# Docker - API
- package-ecosystem: "docker"
directory: "/apps/api"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "docker"
+15 -14
View File
@@ -134,7 +134,7 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
uses: actions/checkout@v6
branch_build_push_admin:
name: Build-Push Admin Docker Image
@@ -142,7 +142,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Admin Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -164,7 +164,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Web Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -186,7 +186,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Space Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -208,7 +208,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Live Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -230,7 +230,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Backend Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -252,7 +252,7 @@ jobs:
needs: [branch_build_setup]
steps:
- name: Proxy Build and Push
uses: makeplane/actions/build-push@v1.0.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -282,7 +282,7 @@ jobs:
- branch_build_push_proxy
steps:
- name: Checkout Files
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Prepare AIO Assets
id: prepare_aio_assets
@@ -298,13 +298,13 @@ jobs:
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
- name: Upload AIO Assets
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
path: ./deployments/aio/community/dist
name: aio-assets-dist
- name: AIO Build and Push
uses: makeplane/actions/build-push@v1.1.0
uses: makeplane/actions/build-push@v1.4.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
@@ -337,7 +337,7 @@ jobs:
- branch_build_push_proxy
steps:
- name: Checkout Files
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Update Assets
run: |
@@ -352,7 +352,7 @@ jobs:
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
- name: Upload Assets
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: community-assets
path: |
@@ -381,7 +381,7 @@ jobs:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Update Assets
run: |
@@ -391,12 +391,13 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2.1.0
uses: softprops/action-gh-release@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ env.REL_VERSION }}
name: ${{ env.REL_VERSION }}
target_commitish: ${{ github.sha }}
draft: false
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
+2 -2
View File
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
+4 -1
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:
@@ -23,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
-25
View File
@@ -1,25 +0,0 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [preview]
pull_request:
branches: [preview]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.22"
+6 -6
View File
@@ -48,7 +48,7 @@ jobs:
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
uses: actions/checkout@v6
full_build_push:
runs-on: ubuntu-22.04
@@ -63,23 +63,23 @@ jobs:
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v6.9.0
uses: docker/build-push-action@v7.0.0
with:
context: .
file: ./aio/Dockerfile-app
@@ -112,7 +112,7 @@ jobs:
sudo apt-get install -y python3-pip
pip3 install awscli
- name: Tailscale
uses: tailscale/github-action@v2
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
@@ -8,8 +8,6 @@ on:
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
@@ -46,7 +44,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -89,7 +87,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -97,7 +95,7 @@ jobs:
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
@@ -112,7 +110,7 @@ jobs:
run: pnpm turbo run build --affected
- name: Save Turbo cache
uses: actions/cache/save@v4
uses: actions/cache/save@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
@@ -146,7 +144,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -187,7 +185,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Cache pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -195,7 +193,7 @@ jobs:
pnpm-store-${{ runner.os }}-
- name: Restore Turbo cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@v5
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
-52
View File
@@ -1,52 +0,0 @@
name: Create PR on Sync
on:
workflow_dispatch:
push:
branches:
- "sync/**"
env:
CURRENT_BRANCH: ${{ github.ref_name }}
TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
create_pull_request:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Setup Git
run: |
git config user.name "$ACCOUNT_USER_NAME"
git config user.email "$ACCOUNT_USER_EMAIL"
- name: Setup GH CLI and Git Config
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Create PR to Target Branch
run: |
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
echo "Pull Request created: $PR_URL"
fi
-44
View File
@@ -1,44 +0,0 @@
name: Sync Repositories
on:
workflow_dispatch:
push:
branches:
- preview
env:
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
jobs:
sync_changes:
runs-on: ubuntu-22.04
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Setup GH CLI
run: |
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
- name: Push Changes to Target Repo
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
+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
@@ -10,7 +10,7 @@
<p align="center">
<a href="https://plane.so/"><b>Website</b></a> •
<a href="https://forum.plane.so"><b>Forum</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://x.com/planepowers"><b>X</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
+2 -2
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
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.8.12
RUN pnpm add -g turbo@2.9.4
COPY . .
@@ -16,8 +16,6 @@ import { Input, ToggleSwitch } from "@plane/ui";
import { ControllerInput } from "@/components/common/controller-input";
// hooks
import { useInstance } from "@/hooks/store";
// components
import { IntercomConfig } from "./intercom";
export interface IGeneralConfigurationForm {
instance: IInstance;
@@ -27,14 +25,13 @@ export interface IGeneralConfigurationForm {
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
const { instance, instanceAdmins } = props;
// hooks
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
const { updateInstanceInfo } = useInstance();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
watch,
} = useForm<Partial<IInstance>>({
defaultValues: {
instance_name: instance?.instance_name,
@@ -45,17 +42,6 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
const onSubmit = async (formData: Partial<IInstance>) => {
const payload: Partial<IInstance> = { ...formData };
// update the intercom configuration
const isIntercomEnabled =
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
try {
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
} catch (error) {
console.error(error);
}
}
await updateInstanceInfo(payload)
.then(() =>
setToast({
@@ -112,8 +98,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div>
<div className="space-y-6">
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Telemetry</div>
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="shrink-0">
@@ -1,86 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { MessageSquare } from "lucide-react";
import type { IFormattedInstanceConfiguration } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// derived values
const isIntercomEnabled = isTelemetryEnabled
? instanceConfigurations
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
? true
: false
: undefined
: false;
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
isTelemetryEnabled ? fetchInstanceConfigurations() : null
);
const initialLoader = isLoading && isIntercomEnabled === undefined;
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
try {
await updateInstanceConfigurations(payload);
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}
};
const enableIntercomConfig = () => {
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
};
return (
<>
<div className="flex items-center gap-14">
<div className="flex grow items-center gap-4">
<div className="shrink-0">
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
<MessageSquare className="size-5 p-0.5 text-tertiary" />
</div>
</div>
<div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div>
<div className="text-11 leading-5 font-regular text-tertiary">
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
automatically.
</div>
</div>
<div className="ml-auto">
<ToggleSwitch
value={isIntercomEnabled ? true : false}
onChange={enableIntercomConfig}
size="sm"
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
/>
</div>
</div>
</div>
</>
);
});
+1 -1
View File
@@ -88,7 +88,7 @@ export function HydrateFallback() {
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
return (
<div>
<p>Something went wrong.</p>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "1.2.0",
"version": "1.3.1",
"private": true,
"description": "Admin UI for Plane",
"license": "AGPL-3.0",
@@ -49,7 +49,6 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "catalog:",
@@ -57,6 +56,7 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import * as dotenv from "@dotenvx/dotenvx";
import * as dotenv from "dotenv";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "plane-api",
"version": "1.2.0",
"license": "AGPL-3.0",
"version": "1.3.1",
"private": true,
"description": "API server powering Plane's backend"
"description": "API server powering Plane's backend",
"license": "AGPL-3.0"
}
+5 -1
View File
@@ -25,6 +25,10 @@ from .issue import (
IssueCommentCreateSerializer,
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
IssueRelationCreateSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import (
@@ -49,7 +53,7 @@ from .intake import (
IntakeIssueCreateSerializer,
IntakeIssueUpdateSerializer,
)
from .estimate import EstimatePointSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer
from .asset import (
UserAssetUploadSerializer,
AssetUpdateSerializer,
+25 -9
View File
@@ -2,20 +2,36 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework import serializers
# Module imports
from plane.db.models import EstimatePoint
from plane.db.models import Estimate, EstimatePoint
from .base import BaseSerializer
class EstimatePointSerializer(BaseSerializer):
"""
Serializer for project estimation points and story point values.
class EstimateSerializer(BaseSerializer):
class Meta:
model = Estimate
fields = "__all__"
read_only_fields = ["workspace", "project", "deleted_at"]
Handles numeric estimation data for work item sizing and sprint planning,
providing standardized point values for project velocity calculations.
"""
def create(self, validated_data):
validated_data["workspace"] = self.context["workspace"]
validated_data["project"] = self.context["project"]
return super().create(validated_data)
class EstimatePointSerializer(BaseSerializer):
def validate(self, data):
if not data:
raise serializers.ValidationError("Estimate points are required")
value = data.get("value")
if value and len(value) > 20:
raise serializers.ValidationError("Value can't be more than 20 characters")
return data
class Meta:
model = EstimatePoint
fields = ["id", "value"]
read_only_fields = fields
fields = "__all__"
read_only_fields = ["estimate", "workspace", "project"]
+187
View File
@@ -20,6 +20,7 @@ from plane.db.models import (
IssueComment,
IssueLabel,
IssueLink,
IssueRelation,
Label,
ProjectMember,
State,
@@ -479,6 +480,192 @@ 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.
Each list contains project_id and issue_id pairs so clients can resolve
cross-project relations.
"""
blocking = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Work items blocking this issue",
)
blocked_by = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Work items this issue is blocked by",
)
duplicate = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Duplicate work items",
)
relates_to = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Related work items",
)
start_after = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Work items that start after this issue",
)
start_before = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Work items that start before this issue",
)
finish_after = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Work items that finish after this issue",
)
finish_before = serializers.ListField(
child=IssueRelationRefSerializer(),
help_text="Work items that finish before this issue",
)
class IssueRelationCreateSerializer(serializers.Serializer):
"""
Serializer for creating issue relations.
Creates issue relations with the specified relation type and issues.
Validates relation types and ensures proper issue ID format.
"""
RELATION_TYPE_CHOICES = [
("blocking", "Blocking"),
("blocked_by", "Blocked By"),
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("start_before", "Start Before"),
("start_after", "Start After"),
("finish_before", "Finish Before"),
("finish_after", "Finish After"),
]
relation_type = serializers.ChoiceField(
choices=RELATION_TYPE_CHOICES,
required=True,
help_text="Type of relationship between work items",
)
issues = serializers.ListField(
child=serializers.UUIDField(),
required=True,
min_length=1,
help_text="Array of work item IDs to create relations with",
)
def validate_issues(self, value):
"""Validate that issues list is not empty and contains valid UUIDs."""
if not value:
raise serializers.ValidationError("At least one issue ID is required.")
return value
class IssueRelationRemoveSerializer(serializers.Serializer):
"""
Serializer for removing issue relations.
Removes existing relationships between work items by specifying
the related issue ID.
"""
related_issue = serializers.UUIDField(
required=True, help_text="ID of the related work item to remove relation with"
)
class IssueRelationSerializer(BaseSerializer):
"""
Serializer for issue relationships showing related issue details.
Provides comprehensive information about related issues including
project context, sequence ID, and relationship type.
"""
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.UUIDField(source="related_issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
priority = serializers.CharField(source="related_issue.priority", read_only=True)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"created_by",
"created_at",
"updated_at",
"updated_by",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
class RelatedIssueSerializer(BaseSerializer):
"""
Serializer for reverse issue relationships showing issue details.
Provides comprehensive information about the source issue in a relationship
including project context, sequence ID, and relationship type.
"""
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
relation_type = serializers.CharField(read_only=True)
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
priority = serializers.CharField(source="issue.priority", read_only=True)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"type_id",
"is_epic",
"state_id",
"priority",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
class IssueAttachmentSerializer(BaseSerializer):
"""
Serializer for work item file attachments.
+29
View File
@@ -0,0 +1,29 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.api.views.estimate import (
ProjectEstimateAPIEndpoint,
EstimatePointListCreateAPIEndpoint,
EstimatePointDetailAPIEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]),
name="project-estimate",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="estimate-point-list-create",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:estimate_point_id>/",
EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]),
name="estimate-point-detail",
),
]
+6
View File
@@ -17,6 +17,7 @@ from plane.api.views import (
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
)
# Deprecated url patterns
@@ -145,6 +146,11 @@ new_url_patterns = [
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-attachment-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/",
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-relation-list",
),
]
urlpatterns = old_url_patterns + new_url_patterns
+1
View File
@@ -29,6 +29,7 @@ from .issue import (
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
)
from .cycle import (
+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")
+291
View File
@@ -0,0 +1,291 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
# Module imports
from plane.app.permissions.project import ProjectEntityPermission
from plane.api.views.base import BaseAPIView
from plane.db.models import Estimate, EstimatePoint, Project, Workspace
from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
from plane.utils.openapi.decorators import estimate_docs, estimate_point_docs
from plane.utils.openapi import (
ESTIMATE_CREATE_EXAMPLE,
ESTIMATE_UPDATE_EXAMPLE,
ESTIMATE_POINT_CREATE_EXAMPLE,
ESTIMATE_POINT_UPDATE_EXAMPLE,
ESTIMATE_EXAMPLE,
ESTIMATE_POINT_EXAMPLE,
DELETED_RESPONSE,
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ESTIMATE_ID_PARAMETER,
)
class ProjectEstimateAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
model = Estimate
serializer_class = EstimateSerializer
def get_queryset(self):
return self.model.objects.filter(workspace__slug=self.workspace_slug, project_id=self.project_id)
@estimate_docs(
operation_id="create_estimate",
summary="Create an estimate",
description="Create an estimate for a project",
request=OpenApiRequest(
request=EstimateSerializer,
examples=[ESTIMATE_CREATE_EXAMPLE],
),
)
def post(self, request, slug, project_id):
project = Project.objects.filter(id=project_id, workspace__slug=slug).first()
if not project:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Project not found"})
workspace = Workspace.objects.filter(slug=slug).first()
if not workspace:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Workspace not found"})
project_estimate = self.get_queryset().first()
if project_estimate:
# return 409 if the project estimate already exists
return Response(
status=status.HTTP_409_CONFLICT,
data={"error": "An estimate already exists for this project", "id": str(project_estimate.id)},
)
# create the project estimate
serializer = self.serializer_class(data=request.data, context={"workspace": workspace, "project": project})
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@estimate_docs(
operation_id="get_estimate",
summary="Get an estimate",
description="Get an estimate for a project",
responses={
200: OpenApiResponse(
description="Estimate",
response=EstimateSerializer,
examples=[ESTIMATE_EXAMPLE],
),
},
)
def get(self, request, slug, project_id):
estimate = self.get_queryset().first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
serializer = self.serializer_class(estimate)
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_docs(
operation_id="update_estimate",
summary="Update an estimate",
description="Update an estimate for a project",
request=OpenApiRequest(
request=EstimateSerializer,
examples=[ESTIMATE_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Estimate",
response=EstimateSerializer,
examples=[ESTIMATE_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id):
ALLOWED_FIELDS = ["name", "description"]
estimate = self.get_queryset().first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
if not filtered_data:
serializer = self.serializer_class(estimate)
return Response(serializer.data, status=status.HTTP_200_OK)
serializer = self.serializer_class(estimate, data=filtered_data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_docs(
operation_id="delete_estimate",
summary="Delete an estimate",
description="Delete an estimate for a project",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id):
estimate = self.get_queryset().first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
estimate.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class EstimatePointListCreateAPIEndpoint(BaseAPIView):
"""List and bulk create estimate points for an estimate."""
permission_classes = [ProjectEntityPermission]
model = EstimatePoint
serializer_class = EstimatePointSerializer
def get_queryset(self):
return self.model.objects.filter(
estimate_id=self.kwargs["estimate_id"],
workspace__slug=self.kwargs["slug"],
project_id=self.kwargs["project_id"],
).select_related("estimate", "workspace", "project")
@estimate_point_docs(
operation_id="get_estimate_points",
summary="Get estimate points",
description="Get estimate points for an estimate",
parameters=[
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
ESTIMATE_ID_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Estimate points",
response=EstimatePointSerializer(many=True),
examples=[ESTIMATE_POINT_EXAMPLE],
),
},
)
def get(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.filter(
id=estimate_id,
workspace__slug=slug,
project_id=project_id,
).first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
estimate_points = self.get_queryset()
serializer = self.serializer_class(estimate_points, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_point_docs(
operation_id="create_estimate_points",
summary="Create estimate points",
description="Create estimate points for an estimate",
request=OpenApiRequest(
request=EstimatePointSerializer,
examples=[ESTIMATE_POINT_CREATE_EXAMPLE],
),
responses={
201: OpenApiResponse(
description="Estimate points",
response=EstimatePointSerializer(many=True),
examples=[ESTIMATE_POINT_EXAMPLE],
),
},
)
def post(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.filter(
id=estimate_id,
workspace__slug=slug,
project_id=project_id,
).first()
if not estimate:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
estimate_points_data = (
request.data if isinstance(request.data, list) else request.data.get("estimate_points", [])
)
if not estimate_points_data:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"error": "Estimate points are required"},
)
serializer = self.serializer_class(data=estimate_points_data, many=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
estimate_points = [
EstimatePoint(
estimate=estimate,
workspace=estimate.workspace,
project=estimate.project,
**item,
)
for item in serializer.validated_data
]
created = EstimatePoint.objects.bulk_create(estimate_points)
return Response(
self.serializer_class(created, many=True).data,
status=status.HTTP_201_CREATED,
)
class EstimatePointDetailAPIEndpoint(BaseAPIView):
"""Update and delete a single estimate point."""
permission_classes = [ProjectEntityPermission]
model = EstimatePoint
serializer_class = EstimatePointSerializer
def get_queryset(self):
return self.model.objects.filter(
estimate_id=self.kwargs["estimate_id"],
workspace__slug=self.kwargs["slug"],
project_id=self.kwargs["project_id"],
)
@estimate_point_docs(
operation_id="update_estimate_point",
summary="Update an estimate point",
description="Update an estimate point for an estimate",
request=OpenApiRequest(
request=EstimatePointSerializer,
examples=[ESTIMATE_POINT_UPDATE_EXAMPLE],
),
responses={
200: OpenApiResponse(
description="Estimate point",
response=EstimatePointSerializer,
examples=[ESTIMATE_POINT_EXAMPLE],
),
},
)
def patch(self, request, slug, project_id, estimate_id, estimate_point_id):
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
if not estimate_point:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
ALLOWED_FIELDS = ["key", "value", "description"]
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
if not filtered_data:
return Response(self.serializer_class(estimate_point).data, status=status.HTTP_200_OK)
serializer = self.serializer_class(estimate_point, data=filtered_data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@estimate_point_docs(
operation_id="delete_estimate_point",
summary="Delete an estimate point",
description="Delete an estimate point for an estimate",
responses={
204: DELETED_RESPONSE,
},
)
def delete(self, request, slug, project_id, estimate_id, estimate_point_id):
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
if not estimate_point:
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
estimate_point.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+333 -13
View File
@@ -24,6 +24,7 @@ from django.db.models import (
When,
Subquery,
)
from django.utils import timezone
from django.conf import settings
@@ -45,6 +46,9 @@ from plane.api.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
IssueRelationCreateSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
IssueAttachmentUploadSerializer,
@@ -53,6 +57,7 @@ from plane.api.serializers import (
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
LabelCreateUpdateSerializer,
RelatedIssueSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -66,6 +71,7 @@ from plane.db.models import (
FileAsset,
IssueComment,
IssueLink,
IssueRelation,
Label,
Project,
ProjectMember,
@@ -73,13 +79,16 @@ 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
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
from plane.utils.openapi import (
work_item_docs,
work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@@ -629,6 +638,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity for webhook dispatch
model_activity.delay(
model_name="issue",
model_id=str(issue.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
# If the serializer is not valid, respond with 400 bad
@@ -677,6 +696,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity for webhook dispatch
model_activity.delay(
model_name="issue",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
@@ -752,6 +781,16 @@ class IssueDetailAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity for webhook dispatch
model_activity.delay(
model_name="issue",
model_id=str(pk),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1089,9 +1128,9 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_links: (
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
),
)
@issue_link_docs(
@@ -1196,9 +1235,9 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_links: (
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
),
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
@@ -1347,9 +1386,9 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_comments: IssueCommentSerializer(
issue_comments, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_comments: (
IssueCommentSerializer(issue_comments, many=True, fields=self.fields, expand=self.expand).data
),
)
@issue_comment_docs(
@@ -1658,9 +1697,9 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
issue_activity, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_activity: (
IssueActivitySerializer(issue_activity, many=True, fields=self.fields, expand=self.expand).data
),
)
@@ -1820,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")
@@ -2220,3 +2259,284 @@ class IssueSearchEndpoint(BaseAPIView):
)[: int(limit)]
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
class IssueRelationListCreateAPIEndpoint(BaseAPIView):
"""Issue Relation List and Create Endpoint"""
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@work_item_relation_docs(
operation_id="list_work_item_relations",
summary="List work item relations",
description="Retrieve all relationships for a work item including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after relations.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Work item relations grouped by relation type",
response=IssueRelationResponseSerializer,
examples=[
OpenApiExample(
name="Work Item Relations Response",
value={
"blocking": [
{
"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",
},
],
"duplicate": [],
"relates_to": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"issue_id": "550e8400-e29b-41d4-a716-446655440003",
},
],
"start_after": [],
"start_before": [
{
"project_id": "550e8400-e29b-41d4-a716-446655440012",
"issue_id": "550e8400-e29b-41d4-a716-446655440004",
},
],
"finish_after": [],
"finish_before": [],
},
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id, issue_id):
"""List work item relations
Retrieve all relationships for a work item organized by relation type.
Returns a structured response with relations grouped by type.
"""
relations = IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
workspace__slug=slug,
).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": [],
"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)
@work_item_relation_docs(
operation_id="create_work_item_relation",
summary="Create work item relation",
description="Create relationships between work items. Supports various relation types including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IssueRelationCreateSerializer,
examples=[
OpenApiExample(
name="Create blocking relation",
value={
"relation_type": "blocking",
"issues": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
},
)
],
),
responses={
201: OpenApiResponse(
description="Work item relations created successfully",
response=IssueRelationSerializer(many=True),
examples=[
OpenApiExample(
name="Relations created",
value=[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Fix authentication bug",
"sequence_id": 42,
"project_id": "550e8400-e29b-41d4-a716-446655440001",
"relation_type": "blocked_by",
"state_id": "550e8400-e29b-41d4-a716-446655440002",
"priority": "high",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"created_by": "550e8400-e29b-41d4-a716-446655440004",
"updated_by": "550e8400-e29b-41d4-a716-446655440004",
}
],
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, issue_id):
"""Create work item relation
Create relationships between work items with specified relation type.
Automatically tracks relation creation activity.
"""
# Validate request data using serializer
serializer = IssueRelationCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
relation_type = serializer.validated_data["relation_type"]
issues = serializer.validated_data["issues"]
project = Project.objects.get(pk=project_id, workspace__slug=slug)
actual_relation = get_actual_relation(relation_type)
is_reverse = relation_type in ["blocking", "start_after", "finish_after"]
IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if is_reverse else issue_id),
related_issue_id=(issue_id if is_reverse else issue),
relation_type=actual_relation,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
# Re-fetch with select_related to avoid N+1 queries in serializers.
# bulk_create with ignore_conflicts=True may not return PKs,
# so query by the issue/related_issue pairs and relation type.
if is_reverse:
refetch_filter = Q(
issue_id__in=issues,
related_issue_id=issue_id,
relation_type=actual_relation,
)
else:
refetch_filter = Q(
issue_id=issue_id,
related_issue_id__in=issues,
relation_type=actual_relation,
)
refetched_relations = IssueRelation.objects.filter(
refetch_filter,
workspace__slug=slug,
).select_related(
"issue__state",
"related_issue__state",
)
serializer_class = RelatedIssueSerializer if is_reverse else IssueRelationSerializer
return Response(
serializer_class(refetched_relations, many=True).data,
status=status.HTTP_201_CREATED,
)
+11
View File
@@ -22,6 +22,17 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
def _wrapped_view(instance, request, *args, **kwargs):
# Check for creator if required
if creator and model:
# check if the user is part of the workspace or not
if not WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=kwargs["slug"],
is_active=True,
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
if obj:
return view_func(instance, request, *args, **kwargs)
+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:
+1 -6
View File
@@ -3,7 +3,7 @@
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint
urlpatterns = [
# API Tokens
@@ -17,10 +17,5 @@ urlpatterns = [
ApiTokenEndpoint.as_view(),
name="api-tokens-details",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
## End API Tokens
]
+1 -1
View File
@@ -165,7 +165,7 @@ from .module.issue import ModuleIssueViewSet
from .module.archive import ModuleArchiveUnarchiveEndpoint
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .api import ApiTokenEndpoint
from .page.base import (
PageViewSet,
+14 -41
View File
@@ -29,7 +29,7 @@ from plane.db.models import (
Module,
)
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.analytics_plot import build_graph_plot, VALID_ANALYTICS_FIELDS, VALID_YAXIS
from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE
@@ -41,32 +41,15 @@ class AnalyticsEndpoint(BaseAPIView):
y_axis = request.GET.get("y_axis", False)
segment = request.GET.get("segment", False)
valid_xaxis_segment = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
@@ -214,13 +197,20 @@ class SavedAnalyticEndpoint(BaseAPIView):
x_axis = analytic_view.query_dict.get("x_axis", False)
y_axis = analytic_view.query_dict.get("y_axis", False)
if not x_axis or not y_axis:
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
return Response(
{"error": "x-axis and y-axis dimensions are required"},
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
segment = request.GET.get("segment", False)
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
total_issues = queryset.count()
return Response(
@@ -236,32 +226,15 @@ class ExportAnalyticsEndpoint(BaseAPIView):
y_axis = request.data.get("y_axis", False)
segment = request.data.get("segment", False)
valid_xaxis_segment = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
valid_yaxis = ["issue_count", "estimate"]
# Check for x-axis and y-axis as thery are required parameters
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
return Response(
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
status=status.HTTP_400_BAD_REQUEST,
)
# If segment is present it cannot be same as x-axis
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
return Response(
{"error": "Both segment and x axis cannot be same and segment should be valid"},
status=status.HTTP_400_BAD_REQUEST,
+1 -27
View File
@@ -13,9 +13,8 @@ from rest_framework import status
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken, Workspace
from plane.db.models import APIToken
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
@@ -61,28 +60,3 @@ class ApiTokenEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
if api_token:
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0
api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
+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"),
+6 -2
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
@@ -64,7 +65,10 @@ class IssueAttachmentEndpoint(BaseAPIView):
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
).first()
if not issue_attachment:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Issue attachment not found."},
status=status.HTTP_404_NOT_FOUND,
)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
@@ -94,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))
+1 -1
View File
@@ -1118,7 +1118,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
epoch = int(timezone.now().timestamp())
# Fetch all relevant issues in a single query
issues = list(Issue.objects.filter(id__in=issue_ids))
issues = list(Issue.objects.filter(id__in=issue_ids, workspace__slug=slug, project_id=project_id))
issues_dict = {str(issue.id): issue for issue in issues}
issues_to_update = []
+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
+29 -14
View File
@@ -226,21 +226,36 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True,
)
if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]:
return Response(
{"error": "You cannot add a user with role higher than the workspace role"},
status=status.HTTP_400_BAD_REQUEST,
)
if "role" in request.data:
# Only Admins can modify roles
if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin:
return Response(
{"error": "You do not have permission to update roles"},
status=status.HTTP_403_FORBIDDEN,
)
if (
"role" in request.data
and int(request.data.get("role", project_member.role)) > requested_project_member.role
and not is_workspace_admin
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
# Cannot modify a member whose role is equal to or higher than your own
if project_member.role >= requested_project_member.role and not is_workspace_admin:
return Response(
{"error": "You cannot update the role of a member with a role equal to or higher than your own"},
status=status.HTTP_403_FORBIDDEN,
)
new_role = int(request.data.get("role"))
# Cannot assign a role equal to or higher than your own
if new_role >= requested_project_member.role and not is_workspace_admin:
return Response(
{"error": "You cannot assign a role equal to or higher than your own"},
status=status.HTTP_403_FORBIDDEN,
)
# Cannot assign a role higher than the target's workspace role
if workspace_role in [5] and new_role in [15, 20]:
return Response(
{"error": "You cannot add a user with role higher than the workspace role"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)
+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)
+52 -24
View File
@@ -13,7 +13,7 @@ from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Dict, Any, Tuple
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
@@ -66,6 +66,52 @@ def validate_url_ip(url: str) -> None:
MAX_REDIRECTS = 5
def safe_get(
url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = 1,
) -> Tuple[requests.Response, str]:
"""
Perform a GET request that validates every redirect hop against private IPs.
Prevents SSRF by ensuring no redirect lands on a private/internal address.
Args:
url: The URL to fetch
headers: Optional request headers
timeout: Request timeout in seconds
Returns:
A tuple of (final Response object, final URL after redirects)
Raises:
ValueError: If any URL in the redirect chain points to a private IP
requests.RequestException: On network errors
RuntimeError: If max redirects exceeded
"""
validate_url_ip(url)
current_url = url
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
)
redirect_count = 0
while response.is_redirect:
if redirect_count >= MAX_REDIRECTS:
raise RuntimeError(f"Too many redirects for URL: {url}")
redirect_url = response.headers.get("Location")
if not redirect_url:
break
current_url = urljoin(current_url, redirect_url)
validate_url_ip(current_url)
redirect_count += 1
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
)
return response, current_url
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
@@ -86,26 +132,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
title = None
final_url = url
validate_url_ip(final_url)
try:
# Manually follow redirects to validate each URL before requesting
redirect_count = 0
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
while response.is_redirect and redirect_count < MAX_REDIRECTS:
redirect_url = response.headers.get("Location")
if not redirect_url:
break
# Resolve relative redirects against current URL
final_url = urljoin(final_url, redirect_url)
# Validate the redirect target BEFORE making the request
validate_url_ip(final_url)
redirect_count += 1
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
if redirect_count >= MAX_REDIRECTS:
logger.warning(f"Too many redirects for URL: {url}")
response, final_url = safe_get(url, headers=headers)
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
@@ -113,8 +141,10 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
except (ValueError, RuntimeError) as e:
logger.warning(f"URL validation failed: {str(e)}")
# Fetch and encode favicon using final URL (after redirects)
# Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
# Prepare result
@@ -204,9 +234,7 @@ def fetch_and_encode_favicon(
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
validate_url_ip(favicon_url)
response = requests.get(favicon_url, headers=headers, timeout=1)
response, _ = safe_get(favicon_url, headers=headers)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
@@ -0,0 +1,18 @@
# Generated by Django 4.2.28 on 2026-02-26 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0120_issueview_archived_at'),
]
operations = [
migrations.AlterField(
model_name='estimate',
name='type',
field=models.CharField(choices=[('categories', 'Categories'), ('points', 'Points')], default='categories', max_length=255),
),
]
+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}"
+5 -1
View File
@@ -10,11 +10,15 @@ from django.db.models import Q
# Module imports
from .project import ProjectBaseModel
class EstimateType(models.TextChoices):
CATEGORIES = "categories", "Categories"
POINTS = "points", "Points"
class Estimate(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(verbose_name="Estimate Description", blank=True)
type = models.CharField(max_length=255, default="categories")
type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES)
last_used = models.BooleanField(default=False)
def __str__(self):
+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}"
@@ -45,7 +45,8 @@ class InstanceConfigurationEndpoint(BaseAPIView):
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
raw_value = request.data.get(configuration.key, configuration.value)
value = "" if raw_value is None else str(raw_value).strip()
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
@@ -63,8 +63,6 @@ class InstanceEndpoint(BaseAPIView):
POSTHOG_HOST,
UNSPLASH_ACCESS_KEY,
LLM_API_KEY,
IS_INTERCOM_ENABLED,
INTERCOM_APP_ID,
) = get_configuration_value(
[
{
@@ -124,15 +122,6 @@ class InstanceEndpoint(BaseAPIView):
"key": "LLM_API_KEY",
"default": os.environ.get("LLM_API_KEY", ""),
},
# Intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
},
{
"key": "INTERCOM_APP_ID",
"default": os.environ.get("INTERCOM_APP_ID", ""),
},
]
)
@@ -169,10 +158,6 @@ class InstanceEndpoint(BaseAPIView):
# is smtp configured
data["is_smtp_configured"] = bool(EMAIL_HOST)
# Intercom settings
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
data["intercom_app_id"] = INTERCOM_APP_ID
# Base URL
data["admin_base_url"] = settings.ADMIN_BASE_URL
data["space_base_url"] = settings.SPACE_BASE_URL
+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", "")
@@ -0,0 +1,214 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# 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""):
"""Create a mock requests.Response."""
resp = MagicMock()
resp.status_code = status_code
resp.is_redirect = is_redirect
resp.headers = headers or {}
resp.content = content
return resp
@pytest.mark.unit
class TestValidateUrlIp:
"""Test validate_url_ip blocks private/internal IPs."""
def test_rejects_private_ip(self):
with patch("plane.bgtasks.work_item_link_task.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_ip("http://example.com")
def test_rejects_loopback(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://example.com")
def test_rejects_non_http_scheme(self):
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
validate_url_ip("file:///etc/passwd")
def test_allows_public_ip(self):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
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."""
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_returns_response_for_non_redirect(self, mock_validate, mock_get):
final_resp = _make_response(status_code=200, content=b"OK")
mock_get.return_value = final_resp
response, final_url = safe_get("https://example.com")
assert response is final_resp
assert final_url == "https://example.com"
mock_validate.assert_called_once_with("https://example.com")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_follows_redirect_and_validates_each_hop(self, mock_validate, mock_get):
redirect_resp = _make_response(
status_code=301,
is_redirect=True,
headers={"Location": "https://other.com/page"},
)
final_resp = _make_response(status_code=200, content=b"OK")
mock_get.side_effect = [redirect_resp, final_resp]
response, final_url = safe_get("https://example.com")
assert response is final_resp
assert final_url == "https://other.com/page"
# Should validate both the initial URL and the redirect target
assert mock_validate.call_count == 2
mock_validate.assert_any_call("https://example.com")
mock_validate.assert_any_call("https://other.com/page")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_blocks_redirect_to_private_ip(self, mock_validate, mock_get):
redirect_resp = _make_response(
status_code=302,
is_redirect=True,
headers={"Location": "http://192.168.1.1:8080"},
)
mock_get.return_value = redirect_resp
# First call (initial URL) succeeds, second call (redirect target) fails
mock_validate.side_effect = [None, ValueError("Access to private/internal networks is not allowed")]
with pytest.raises(ValueError, match="private/internal"):
safe_get("https://evil.com/redirect")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_raises_on_too_many_redirects(self, mock_validate, mock_get):
redirect_resp = _make_response(
status_code=302,
is_redirect=True,
headers={"Location": "https://example.com/loop"},
)
mock_get.return_value = redirect_resp
with pytest.raises(RuntimeError, match="Too many redirects"):
safe_get("https://example.com/start")
@patch("plane.bgtasks.work_item_link_task.requests.get")
@patch("plane.bgtasks.work_item_link_task.validate_url_ip")
def test_succeeds_at_exact_max_redirects(self, mock_validate, mock_get):
"""After exactly MAX_REDIRECTS hops, if the final response is 200, it should succeed."""
redirect_resp = _make_response(
status_code=302,
is_redirect=True,
headers={"Location": "https://example.com/next"},
)
final_resp = _make_response(status_code=200, content=b"OK")
# 5 redirects then a 200
mock_get.side_effect = [redirect_resp] * 5 + [final_resp]
response, final_url = safe_get("https://example.com/start")
assert response is final_resp
assert not response.is_redirect
+26
View File
@@ -22,6 +22,23 @@ from django.utils import timezone
# Module imports
from plane.db.models import Issue, Project
VALID_ANALYTICS_FIELDS = [
"state_id",
"state__group",
"labels__id",
"assignees__id",
"estimate_point__value",
"issue_cycle__cycle_id",
"issue_module__module_id",
"priority",
"start_date",
"target_date",
"created_at",
"completed_at",
]
VALID_YAXIS = ["issue_count", "estimate"]
def annotate_with_monthly_dimension(queryset, field_name, attribute):
# Get the year and the months
@@ -34,6 +51,8 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute):
def extract_axis(queryset, x_axis):
if x_axis not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid x_axis value: {x_axis}")
# Format the dimension when the axis is in date
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
@@ -52,6 +71,13 @@ def sort_data(data, temp_axis):
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
if x_axis not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid x_axis value: {x_axis}")
if y_axis not in VALID_YAXIS:
raise ValueError(f"Invalid y_axis value: {y_axis}")
if segment and segment not in VALID_ANALYTICS_FIELDS:
raise ValueError(f"Invalid segment value: {segment}")
# temp x_axis
temp_axis = x_axis
# Extract the x_axis and queryset
@@ -232,21 +232,6 @@ unsplash_config_variables = [
},
]
intercom_config_variables = [
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
core_config_variables = [
*authentication_config_variables,
*workspace_management_config_variables,
@@ -257,5 +242,4 @@ core_config_variables = [
*smtp_config_variables,
*llm_config_variables,
*unsplash_config_variables,
*intercom_config_variables,
]
+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:
+20
View File
@@ -47,6 +47,7 @@ from .parameters import (
CYCLE_VIEW_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
ESTIMATE_ID_PARAMETER,
)
# Responses
@@ -126,6 +127,10 @@ from .examples import (
STATE_UPDATE_EXAMPLE,
INTAKE_ISSUE_CREATE_EXAMPLE,
INTAKE_ISSUE_UPDATE_EXAMPLE,
ESTIMATE_CREATE_EXAMPLE,
ESTIMATE_UPDATE_EXAMPLE,
ESTIMATE_POINT_CREATE_EXAMPLE,
ESTIMATE_POINT_UPDATE_EXAMPLE,
# Response Examples
CYCLE_EXAMPLE,
TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
@@ -145,6 +150,8 @@ from .examples import (
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
ESTIMATE_EXAMPLE,
ESTIMATE_POINT_EXAMPLE,
)
# Helper decorators
@@ -157,6 +164,7 @@ from .decorators import (
user_docs,
cycle_docs,
work_item_docs,
work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@@ -165,6 +173,8 @@ from .decorators import (
module_docs,
module_issue_docs,
state_docs,
estimate_docs,
estimate_point_docs,
)
# Schema processing hooks
@@ -206,6 +216,7 @@ __all__ = [
"CYCLE_VIEW_PARAMETER",
"FIELDS_PARAMETER",
"EXPAND_PARAMETER",
"ESTIMATE_ID_PARAMETER",
# Responses
"UNAUTHORIZED_RESPONSE",
"FORBIDDEN_RESPONSE",
@@ -279,6 +290,10 @@ __all__ = [
"STATE_UPDATE_EXAMPLE",
"INTAKE_ISSUE_CREATE_EXAMPLE",
"INTAKE_ISSUE_UPDATE_EXAMPLE",
"ESTIMATE_CREATE_EXAMPLE",
"ESTIMATE_UPDATE_EXAMPLE",
"ESTIMATE_POINT_CREATE_EXAMPLE",
"ESTIMATE_POINT_UPDATE_EXAMPLE",
# Response Examples
"CYCLE_EXAMPLE",
"TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE",
@@ -298,6 +313,8 @@ __all__ = [
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
"ESTIMATE_EXAMPLE",
"ESTIMATE_POINT_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -307,6 +324,7 @@ __all__ = [
"user_docs",
"cycle_docs",
"work_item_docs",
"work_item_relation_docs",
"label_docs",
"issue_link_docs",
"issue_comment_docs",
@@ -315,6 +333,8 @@ __all__ = [
"module_docs",
"module_issue_docs",
"state_docs",
"estimate_docs",
"estimate_point_docs",
# Hooks
"preprocess_filter_api_v1_paths",
"generate_operation_summary",
@@ -223,6 +223,21 @@ def issue_attachment_docs(**kwargs):
return extend_schema(**_merge_schema_options(defaults, kwargs))
def work_item_relation_docs(**kwargs):
"""Decorator for work item relation endpoints"""
defaults = {
"tags": ["Work Item Relations"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def module_docs(**kwargs):
"""Decorator for module management endpoints"""
defaults = {
@@ -282,3 +297,29 @@ def sticky_docs(**kwargs):
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def estimate_docs(**kwargs):
"""Decorator for estimate-related endpoints"""
defaults = {
"tags": ["Estimates"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def estimate_point_docs(**kwargs):
"""Decorator for estimate point-related endpoints"""
defaults = {
"tags": ["Estimate Points"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
+83
View File
@@ -686,6 +686,69 @@ STICKY_EXAMPLE = OpenApiExample(
},
)
# Estimate Examples
ESTIMATE_EXAMPLE = OpenApiExample(
name="Estimate",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Estimate 1",
"description": "Estimate 1 description",
},
description="Example response for an estimate",
)
ESTIMATE_POINT_EXAMPLE = OpenApiExample(
name="EstimatePoint",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"estimate": "550e8400-e29b-41d4-a716-446655440001",
"key": 1,
"value": "1",
},
description="Example response for an estimate point",
)
ESTIMATE_CREATE_EXAMPLE = OpenApiExample(
name="EstimateCreateSerializer",
value={
"name": "Estimate 1",
"description": "Estimate 1 description",
},
description="Example request for creating an estimate",
)
ESTIMATE_UPDATE_EXAMPLE = OpenApiExample(
name="EstimateUpdateSerializer",
value={
"name": "Estimate 1",
"description": "Estimate 1 description",
},
description="Example request for updating an estimate",
)
# Estimate Point Examples
ESTIMATE_POINT_CREATE_EXAMPLE = OpenApiExample(
name="EstimatePointCreateSerializer",
value=[
{
"value": "1",
"description": "Estimate Point 1 description",
},
{
"value": "2",
"description": "Estimate Point 2 description",
},
],
description="Example request for creating an estimate point",
)
ESTIMATE_POINT_UPDATE_EXAMPLE = OpenApiExample(
name="EstimatePointUpdateSerializer",
value={
"value": "1",
"description": "Estimate Point 1 description",
},
description="Example request for updating an estimate point",
)
# Sample data for different entity types
SAMPLE_ISSUE = {
"id": "550e8400-e29b-41d4-a716-446655440000",
@@ -801,6 +864,24 @@ SAMPLE_STICKY = {
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_ESTIMATE = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Estimate 1",
"description": "Estimate 1 description",
"type": "categories",
"last_used": False,
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_ESTIMATE_POINT = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"estimate": "550e8400-e29b-41d4-a716-446655440001",
"key": 1,
"value": "1",
"description": "Estimate Point 1 description",
"created_at": "2024-01-01T10:30:00Z",
}
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -816,6 +897,8 @@ SCHEMA_EXAMPLES = {
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
"Estimate": SAMPLE_ESTIMATE,
"EstimatePoint": SAMPLE_ESTIMATE_POINT,
}
@@ -495,3 +495,11 @@ EXPAND_PARAMETER = OpenApiParameter(
),
],
)
ESTIMATE_ID_PARAMETER = OpenApiParameter(
name="estimate_id",
description="Estimate ID",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
)
+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.
+5 -5
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.29
Django==4.2.30
# rest framework
djangorestframework==3.15.2
# postgres
@@ -45,15 +45,15 @@ scout-apm==3.1.0
# xlsx generation
openpyxl==3.1.2
# logging
python-json-logger==3.3.0
python-json-logger==4.0.0
# html parser
beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
cryptography==46.0.5
cryptography==46.0.7
# html validator
lxml==6.0.0
lxml==6.1.0
# s3
boto3==1.34.96
# password validator
@@ -61,7 +61,7 @@ zxcvbn==4.4.28
# timezone
pytz==2024.1
# jwt
PyJWT==2.8.0
PyJWT==2.12.0
# OpenTelemetry
opentelemetry-api==1.28.1
opentelemetry-sdk==1.28.1
+2 -2
View File
@@ -1,6 +1,6 @@
-r base.txt
# test framework
pytest==7.4.0
pytest==9.0.3
pytest-django==4.5.2
pytest-cov==4.1.0
pytest-xdist==3.3.1
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.32.4
requests==2.33.0
@@ -261,7 +261,7 @@
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > ­ </td>
</tr>
<tr>
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
<tr class="nl2go-responsive-hide" >
@@ -233,7 +233,7 @@
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://x.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
</tr>
@@ -155,7 +155,7 @@
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
<td align="left" valign="top" class="r21-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: georgia, serif; font-size: 16px; word-break: break-word; line-height: 1.5; padding-bottom: 5px; padding-left: 5px; padding-right: 5px; padding-top: 5px; text-align: left; " >
<div>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="http://twitter.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
<p style="margin: 0"> <span style="font-size: 13px" >Despite our popularity, we are humbly early-stage. We are shipping fast, so please reach out to us with feature requests, major and minor nits, and anything else you find missing. We read every </span ><a href="https://forum.plane.so" title="Plane Forum" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >message</span ></a ><span style="font-size: 13px" >, </span ><a href="https://x.com/planepowers" title="@planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >tweet</span ></a ><span style="font-size: 13px" >, and </span ><a href="https://github.com/makeplane/plane/issues" title="Plane's GitHub conversations" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >conversation</span ></a ><span style="font-size: 13px" > and update </span ><a href="https://plane.sh/plane/0b170a1c-0e55-47cb-9307-ea49a05672b5?board=kanban" title="Plane's roadmap" target="_blank" style=" color: #006399; text-decoration: underline; " ><span style="font-size: 13px" >our public roadmap</span ></a ><span style="font-size: 13px" >.</span > </p>
</div>
</td>
<td class="nl2go-responsive-hide" width="2" style=" font-size: 0px; line-height: 1px; background-color: #efefef; " > ­ </td>
@@ -220,7 +220,7 @@
<th width="40" class="r25-c mobshow resp-table" style=" font-weight: normal; " >
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style=" table-layout: fixed; width: 100%; " >
<tr>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="r18-i" style=" font-size: 0px; line-height: 0px; padding-bottom: 5px; padding-top: 5px; " > <a href="https://x.com/planepowers" target="_blank" style=" color: #006399; text-decoration: underline; " > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="32" border="0" style=" display: block; width: 100%; " /></a> </td>
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > ­ </td>
</tr>
</table>
@@ -831,7 +831,7 @@
"
>
<a
href="https://twitter.com/planepowers"
href="https://x.com/planepowers"
target="_blank"
style="
color: #006399;
@@ -973,7 +973,7 @@
><span style="font-size: 13px"
>, </span
><a
href="http://twitter.com/planepowers"
href="https://x.com/planepowers"
title="@planepowers"
target="_blank"
style="
@@ -1344,7 +1344,7 @@
"
>
<a
href="https://twitter.com/planepowers"
href="https://x.com/planepowers"
target="_blank"
style="
color: #006399;
@@ -974,7 +974,7 @@
><span style="font-size: 13px"
>, </span
><a
href="http://twitter.com/planepowers"
href="https://x.com/planepowers"
title="@planepowers"
target="_blank"
style="
@@ -1345,7 +1345,7 @@
"
>
<a
href="https://twitter.com/planepowers"
href="https://x.com/planepowers"
target="_blank"
style="
color: #006399;
+2 -2
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
# *****************************************************************************
@@ -15,7 +15,7 @@ RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.8.12
ARG TURBO_VERSION=2.9.4
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=live --docker
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.2.0",
"version": "1.3.1",
"private": true,
"description": "A realtime collaborative server powers Plane's rich text editor",
"license": "AGPL-3.0",
@@ -14,7 +14,7 @@
},
"scripts": {
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
"dev": "tsdown --watch --no-clean --onSuccess \"node --env-file=.env .\"",
"start": "node --env-file=.env .",
"test": "vitest run",
"test:watch": "vitest",
@@ -27,7 +27,6 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@dotenvx/dotenvx": "catalog:",
"@effect/platform": "^0.94.0",
"@effect/platform-node": "^0.104.0",
"@fontsource/inter": "5.2.8",
@@ -47,7 +46,8 @@
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"effect": "^3.16.3",
"dotenv": "catalog:",
"effect": "3.20.0",
"express": "catalog:",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
+1 -1
View File
@@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import * as dotenv from "@dotenvx/dotenvx";
import * as dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
-1
View File
@@ -12,7 +12,6 @@ import {
CODE_COLORS,
LINK_COLORS,
MENTION_COLORS,
NEUTRAL_COLORS,
TEXT_COLORS,
} from "./colors";
+2 -2
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
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.8.12
RUN pnpm add -g turbo@2.9.4
COPY . .
+1 -1
View File
@@ -95,6 +95,6 @@ export function HydrateFallback() {
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
return <ErrorPage />;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type TEditorFlaggingHookReturnType = {
/**
* @description extensions disabled in various editors
*/
export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType => ({
export const useEditorFlagging = (_anchor: string): TEditorFlaggingHookReturnType => ({
document: {
disabled: [],
flagged: [],
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.2.0",
"version": "1.3.1",
"private": true,
"license": "AGPL-3.0",
"type": "module",
@@ -53,7 +53,6 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "catalog:",
@@ -62,6 +61,7 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+3 -3
View File
@@ -9,7 +9,7 @@ import { action, makeObservable, observable, runInAction } from "mobx";
import { SitesCycleService } from "@plane/services";
import type { TPublicCycle } from "@/types/cycle";
// store
import type { CoreRootStore } from "./root.store";
import type { RootStore } from "./root.store";
export interface ICycleStore {
// observables
@@ -23,9 +23,9 @@ export interface ICycleStore {
export class CycleStore implements ICycleStore {
cycles: TPublicCycle[] | undefined = undefined;
cycleService: SitesCycleService;
rootStore: CoreRootStore;
rootStore: RootStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
cycles: observable,
@@ -23,7 +23,7 @@ import type {
} from "@plane/types";
// types
import type { IIssue, TIssuesResponse } from "@/types/issue";
import type { CoreRootStore } from "../root.store";
import type { RootStore } from "../root.store";
// constants
// helpers
@@ -81,7 +81,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
// root store
rootIssueStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observable
loader: observable,
+2 -2
View File
@@ -10,7 +10,7 @@ import { observable, action, makeObservable, runInAction } from "mobx";
import { InstanceService } from "@plane/services";
import type { IInstance, IInstanceConfig } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
type TError = {
status: string;
@@ -40,7 +40,7 @@ export class InstanceStore implements IInstanceStore {
// services
instanceService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
+3 -3
View File
@@ -13,7 +13,7 @@ import { SitesFileService, SitesIssueService } from "@plane/services";
import type { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types";
import { EFileAssetType } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
// types
import type { IIssue, IPeekMode, IVote } from "@/types/issue";
@@ -60,12 +60,12 @@ export class IssueDetailStore implements IIssueDetailStore {
[key: string]: IIssue;
} = {};
// root store
rootStore: CoreRootStore;
rootStore: RootStore;
// services
issueService: SitesIssueService;
fileService: SitesFileService;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
loader: observable.ref,
error: observable.ref,
+2 -2
View File
@@ -11,7 +11,7 @@ import { computedFn } from "mobx-utils";
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants";
import type { IssuePaginationOptions, TIssueParams } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
// types
import type {
TIssueLayoutOptions,
@@ -60,7 +60,7 @@ export class IssueFilterStore implements IIssueFilterStore {
};
filters: { [anchor: string]: TIssueFilters } | undefined = undefined;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
layoutOptions: observable,
+3 -3
View File
@@ -9,7 +9,7 @@ import { action, makeObservable, runInAction } from "mobx";
import { SitesIssueService } from "@plane/services";
import type { IssuePaginationOptions, TLoader } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
// types
import { BaseIssuesStore } from "./helpers/base-issues.store";
import type { IBaseIssuesStore } from "./helpers/base-issues.store";
@@ -28,11 +28,11 @@ export interface IIssueStore extends IBaseIssuesStore {
export class IssueStore extends BaseIssuesStore implements IIssueStore {
// root store
rootStore: CoreRootStore;
rootStore: RootStore;
// services
issueService: SitesIssueService;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
super(_rootStore);
makeObservable(this, {
// actions
+3 -3
View File
@@ -10,7 +10,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
import { SitesLabelService } from "@plane/services";
import type { IIssueLabel } from "@plane/types";
// store
import type { CoreRootStore } from "./root.store";
import type { RootStore } from "./root.store";
export interface IIssueLabelStore {
// observables
@@ -25,9 +25,9 @@ export interface IIssueLabelStore {
export class LabelStore implements IIssueLabelStore {
labelMap: Record<string, IIssueLabel> = {};
labelService: SitesLabelService;
rootStore: CoreRootStore;
rootStore: RootStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
labelMap: observable,
+3 -3
View File
@@ -9,7 +9,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
// plane imports
import { SitesMemberService } from "@plane/services";
import type { TPublicMember } from "@/types/member";
import type { CoreRootStore } from "./root.store";
import type { RootStore } from "./root.store";
export interface IIssueMemberStore {
// observables
@@ -24,9 +24,9 @@ export interface IIssueMemberStore {
export class MemberStore implements IIssueMemberStore {
memberMap: Record<string, TPublicMember> = {};
memberService: SitesMemberService;
rootStore: CoreRootStore;
rootStore: RootStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
memberMap: observable,
+3 -3
View File
@@ -11,7 +11,7 @@ import { SitesModuleService } from "@plane/services";
// types
import type { TPublicModule } from "@/types/modules";
// root store
import type { CoreRootStore } from "./root.store";
import type { RootStore } from "./root.store";
export interface IIssueModuleStore {
// observables
@@ -26,9 +26,9 @@ export interface IIssueModuleStore {
export class ModuleStore implements IIssueModuleStore {
moduleMap: Record<string, TPublicModule> = {};
moduleService: SitesModuleService;
rootStore: CoreRootStore;
rootStore: RootStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
moduleMap: observable,
+2 -2
View File
@@ -11,7 +11,7 @@ import { UserService } from "@plane/services";
import type { TUserProfile } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
// store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
type TError = {
status: string;
@@ -64,7 +64,7 @@ export class ProfileStore implements IProfileStore {
// services
userService: UserService;
constructor(public store: CoreRootStore) {
constructor(public store: RootStore) {
makeObservable(this, {
// observables
isLoading: observable.ref,
+2 -2
View File
@@ -14,7 +14,7 @@ import type {
TProjectPublishViewProps,
} from "@plane/types";
// store
import type { CoreRootStore } from "../root.store";
import type { RootStore } from "../root.store";
export interface IPublishStore extends TProjectPublishSettings {
// computed
@@ -45,7 +45,7 @@ export class PublishStore implements IPublishStore {
workspace_detail: IWorkspaceLite | undefined;
constructor(
private store: CoreRootStore,
private store: RootStore,
publishSettings: TProjectPublishSettings
) {
this.anchor = publishSettings.anchor;
@@ -11,7 +11,7 @@ import { SitesProjectPublishService } from "@plane/services";
import type { TProjectPublishSettings } from "@plane/types";
// store
import { PublishStore } from "@/store/publish/publish.store";
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
export interface IPublishListStore {
// observables
@@ -26,7 +26,7 @@ export class PublishListStore implements IPublishListStore {
// service
publishService;
constructor(private rootStore: CoreRootStore) {
constructor(private rootStore: RootStore) {
makeObservable(this, {
// observables
publishMap: observable,
+3 -3
View File
@@ -12,7 +12,7 @@ import type { IState } from "@plane/types";
// helpers
import { sortStates } from "@/helpers/state.helper";
// store
import type { CoreRootStore } from "./root.store";
import type { RootStore } from "./root.store";
export interface IStateStore {
// observables
@@ -28,9 +28,9 @@ export interface IStateStore {
export class StateStore implements IStateStore {
states: IState[] | undefined = undefined;
stateService: SitesStateService;
rootStore: CoreRootStore;
rootStore: RootStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
states: observable,
+2 -2
View File
@@ -14,7 +14,7 @@ import type { ActorDetail, IUser } from "@plane/types";
import type { IProfileStore } from "@/store/profile.store";
import { ProfileStore } from "@/store/profile.store";
// store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
type TUserErrorStatus = {
status: string;
@@ -50,7 +50,7 @@ export class UserStore implements IUserStore {
// service
userService: UserService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
// stores
this.profile = new ProfileStore(store);
// service
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import * as dotenv from "@dotenvx/dotenvx";
import * as dotenv from "dotenv";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+2 -2
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,7 +14,7 @@ RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.8.12
ARG TURBO_VERSION=2.9.4
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
@@ -4,7 +4,6 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
@@ -11,7 +11,7 @@ import { useRouter } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@plane/propel/tabs";
import { Tabs } from "@plane/propel/tabs";
// components
import { cn } from "@plane/utils";
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
@@ -80,9 +80,9 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
"flex w-full items-center justify-between gap-4 overflow-hidden border-b border-subtle bg-surface-1 px-6 py-2"
)}
>
<TabsList className={"flex h-7 w-fit overflow-x-auto"}>
<Tabs.List className={"flex h-7 w-fit overflow-x-auto"}>
{ANALYTICS_TABS.map((tab) => (
<TabsTrigger
<Tabs.Trigger
key={tab.key}
value={tab.key}
disabled={tab.isDisabled}
@@ -95,22 +95,22 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
}}
>
{tab.label}
</TabsTrigger>
</Tabs.Trigger>
))}
</TabsList>
</Tabs.List>
<div className="flex-shrink-0">
<AnalyticsFilterActions />
</div>
</div>
{ANALYTICS_TABS.map((tab) => (
<TabsContent
<Tabs.Content
key={tab.key}
value={tab.key}
className={"h-full overflow-hidden overflow-y-auto px-2"}
>
<tab.content />
</TabsContent>
</Tabs.Content>
))}
</div>
</Tabs>
@@ -5,7 +5,6 @@
*/
// ui
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { PanelRight } from "lucide-react";
@@ -14,7 +13,6 @@ import { useTranslation } from "@plane/i18n";
import { YourWorkIcon, ChevronDownIcon } from "@plane/propel/icons";
import type { IUserProfileProjectSegregation } from "@plane/types";
import { Breadcrumbs, Header, CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ProfileIssuesFilter } from "@/components/profile/profile-issues-filter";
@@ -4,7 +4,6 @@
* See the LICENSE file for details.
*/
import type { FC } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
// plane helpers
-1
View File
@@ -81,7 +81,6 @@ function UserInvitationsPage() {
.then(() => {
mutate(USER_WORKSPACES_LIST);
const firstInviteId = invitationsRespond[0];
const invitation = invitations?.find((i) => i.id === firstInviteId);
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
updateUserProfile({ last_workspace_id: redirectWorkspace?.id })
.then(() => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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