Compare commits

...

37 Commits

Author SHA1 Message Date
ishan-karmakar d7719d6f39 Remove symlinks because Portainer doesn't like symlinks
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
i18n sync check / check:sync (push) Has been cancelled
2026-06-06 12:49:25 -05:00
ishan-karmakar 36e81f4851 Update docker-compose for our purposes
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-06-06 11:35:48 -05:00
sriram veeraghanta 0bbfe95cc7 fix: bump react-router and vitest to resolve Dependabot advisories (#9215)
* fix: bump react-router and vitest to resolve Dependabot advisories

Resolves 6 open Dependabot alerts (all npm, manifest pnpm-lock.yaml):

- react-router 7.12.0 -> 7.15.0 (fixes GHSA-8x6r-g9mw-2r78 [high],
  GHSA-49rj-9fvp-4h2h [high], GHSA-8646-j5j9-6r62 [high],
  GHSA-2j2x-hqr9-3h42 [medium], GHSA-f22v-gfqf-p8f3 [medium])
- vitest 4.0.x -> 4.1.x (fixes GHSA-5xrq-8626-4rwp [critical])

Aligned lockstep siblings to avoid peer-dependency mismatches:
@react-router/dev|node|serve -> 7.15.0, @vitest/coverage-v8 -> ^4.1.0.

Edited catalog entries in pnpm-workspace.yaml and regenerated
pnpm-lock.yaml; verified with pnpm install --frozen-lockfile.

* fix: raise vitest catalog floor to ^4.1.8 to match security advisory

The critical advisory GHSA-5xrq-8626-4rwp is patched in vitest 4.1.8, but
the catalog specifiers were ^4.1.0, which permits resolving to vulnerable
4.1.0-4.1.7. Align the floor with the documented patched version for vitest
and @vitest/coverage-v8 so a future lockfile refresh cannot reintroduce a
vulnerable Vitest stack. Resolved version is unchanged (4.1.8).
2026-06-05 00:51:33 +05:30
sriram veeraghanta 9a30a07cf5 fix(api): enforce workspace membership on GenericAssetEndpoint (#9212)
The public REST API GenericAssetEndpoint (/api/v1/workspaces/<slug>/assets/)
declared no permission class, inheriting only IsAuthenticated. Since
APIKeyAuthentication does not bind a token to a workspace and the workspace is
read straight from the URL slug, any valid Personal Access Token could read
(GET), create (POST), and modify (PATCH) assets in a workspace the caller is
not a member of — a cross-workspace IDOR, the public-API sibling of the
CVE-2026-46558 dashboard asset fix.

Add permission_classes = [WorkspaceUserPermission] so every method requires
active workspace membership, matching the dashboard fix semantics. Also add
contract regression tests covering cross-workspace GET/POST/PATCH (now 403)
and a positive control confirming members retain access.

Also ignore the local /security/ advisory notes folder.
2026-06-04 18:49:39 +05:30
Karthikeyan Ganesh b6e47ccdae fix: dropdown shadow on the work item more options (#9154)
* fix: UI border and shadow on the dropdown menu usability

* fix: shadow-md and border strong
2026-06-03 17:28:19 +05:30
Durgesh Shekhawat 4280c4d1b1 fix: handle error message for special characters in Identifier of Project (#9059) 2026-06-03 17:26:18 +05:30
sriram veeraghanta b1c78fe4c8 fix(api): rate-limit magic-code verify, bound per-token attempts (GHSA-9pvm-fcf6-9234) (#9130)
* fix(api): rate-limit magic-code verification and bound per-token attempts

The magic-link sign-in / sign-up endpoints accept a 6-digit numeric code
(900k-value space, 600s TTL) but never increment a failure counter on a
wrong-code verify and extend django.views.View rather than DRF APIView,
so DRF's AuthenticationThrottle never runs against them. The space-side
generate endpoint also lacked throttle_classes. Combined, this allowed
an unauthenticated attacker who knew a victim's email to brute-force
the code within the TTL window and log in as the victim.

- Add MAX_VERIFY_ATTEMPTS=5 in MagicCodeProvider.set_user_data: failed
  comparisons now persist verify_attempts in Redis under the remaining
  TTL and, on hitting the limit, delete the key and raise
  EMAIL_CODE_ATTEMPT_EXHAUSTED. This is the load-bearing fix - it caps
  total attempts per issued token regardless of request rate.
- Add authentication_throttle_allows() so plain Django Views can apply
  AuthenticationThrottle without converting to APIView (would change
  CSRF + request-parsing semantics for the redirect-flow endpoints).
- Apply the throttle to MagicSignIn/UpEndpoint and the space variants;
  add throttle_classes to MagicGenerateSpaceEndpoint to match its app
  sibling.

Refs GHSA-9pvm-fcf6-9234.

* fix(api): make verify-attempt increment atomic, expose throttle rate via env

Address PR review feedback:

- Replace the JSON read-modify-write of verify_attempts with a Lua
  EVAL script that INCRs a dedicated counter key and EXPIREs it only
  on the first increment. The previous round-trip was racy: parallel
  wrong-code requests could read the same value and both write the
  same incremented count, letting an attacker exceed MAX_VERIFY_ATTEMPTS
  under concurrency. Counter is now reset on each new token issuance
  and cleared on successful verify / exhaustion.
- Make AuthenticationThrottle.rate configurable via the
  AUTHENTICATION_RATE_LIMIT env var (default 10/minute, down from 30
  to tighten the budget on unauth auth-adjacent endpoints). Document
  it in deployments/aio and deployments/cli variables.env.

* test(api): cover magic-code attempt cap, counter reset, and auth throttle

Add the contract tests called out in the PR test plan:

- TestMagicSignInVerifyAttempts:
  - test_exhausted_after_max_wrong_attempts: after MAX_VERIFY_ATTEMPTS
    wrong codes the next verify redirects with EMAIL_CODE_ATTEMPT_
    EXHAUSTED_SIGN_IN and both Redis keys are deleted; a follow-up
    verify reports EXPIRED.
  - test_counter_increments_on_each_wrong_attempt: the dedicated
    verify_attempts counter advances by exactly one per wrong POST,
    matching the atomic Lua INCR.
  - test_counter_resets_on_token_regeneration: regenerating the
    magic-link clears the counter so the user isn't pre-locked-out by
    a prior session's wrong attempts.
- TestMagicSignUpVerifyAttempts.test_signup_exhausted_after_max_wrong_attempts:
  the sign-up endpoint returns EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP on
  the exhausting attempt.
- TestAuthenticationThrottle: exercises authentication_throttle_allows
  on the plain-View redirect-flow endpoints by patching the rate down
  and asserting RATE_LIMIT_EXCEEDED is appended to the redirect URL
  once the per-IP budget is exceeded, for both magic-sign-in and
  magic-sign-up.

Each new class clears Django cache (DRF throttle storage) and the
per-email Redis keys around every test so runs are independent.

* fix(api): clamp remaining_ttl to >=1 for verify-attempt counter EXPIRE

ri.ttl() returns 0 when the token has less than one second remaining
(Redis floors to whole seconds). The previous clamp only caught
None and < 0, so a sub-second TTL would pass through and the Lua
script's EXPIRE counter 0 would immediately delete the key — letting
an attacker bypass MAX_VERIFY_ATTEMPTS during the final second of the
token's life. Switch the comparison to <= 0.

Narrow real-world impact (sub-second window, throttle still bounds
the rate) but the cap should hold regardless of timing.
2026-06-01 18:44:57 +05:30
sriram veeraghanta 7ec8d4990f fix: bump npm deps to resolve Dependabot advisories (#9191)
* fix: bump npm deps to resolve Dependabot advisories

Resolve 8 open Dependabot alerts (all npm, in pnpm-lock.yaml) by bumping
the affected packages in pnpm-workspace.yaml and regenerating the lockfile:

- axios 1.15.2 -> 1.16.0 (catalog): CVE-2026-44494/44492/44490/44489
- tmp -> 0.2.6 (override): CVE-2026-44705 path traversal
- ws 8.x -> 8.20.1 (catalog + scoped override): CVE-2026-45736
- qs 6.14.2 -> 6.15.2 (override): CVE-2026-8723 DoS
- brace-expansion 5.0.5 -> 5.0.6 (override): CVE-2026-45149 DoS

brace-expansion and qs were pinned to their vulnerable versions in the
overrides block, so the pins had to be bumped directly. ws is scoped to
the 8.x major (ws@7.5.10 is below the vulnerable >=8.0.0 floor). All bumps
are semver-compatible patch/minor upgrades; no source changes required.

* fix: use named axios `create` import after 1.16.0 bump

axios 1.16.0 newly exposes `create` as a named export, so oxlint's
import/no-named-as-default-member rule now flags `axios.create(...)`.
That added one warning to @plane/services (7 > its --max-warnings=6
baseline) and to apps/web and apps/live, failing check:lint — surfaced
on this PR because the lockfile change busts Turbo's lint cache.

Switch the three `axios.create(...)` call sites to a named `{ create }`
import. `create` is a real value+type export in axios 1.16.0 (verified
via tsc). isCancel/CancelToken are left as `axios.*`: CancelToken is
only a type export (cannot be a value import under verbatimModuleSyntax)
and both were already counted within the existing baselines.

Verified locally: full `pnpm check:lint` (16/16) and `check:types`
(15/15) pass.
2026-06-01 18:37:35 +05:30
sriram veeraghanta e388cb9125 fix: declare @tailwindcss/postcss in admin/space/web for Docker builds (#9189)
The web/admin/space Docker image builds fail at the Vite/PostCSS step with
"Cannot find module '@tailwindcss/postcss'". These apps load the shared
@plane/tailwind-config/postcss.config.js, which references the @tailwindcss/postcss
plugin by name, but the plugin was only declared as a dependency of
packages/tailwind-config.

The Docker build installs via turbo prune + 'pnpm fetch' + 'pnpm install --offline',
which lays out node_modules so PostCSS resolves the plugin relative to the app
directory (apps/<app>), where it is not reachable. A plain 'pnpm install' resolves
it from tailwind-config's context instead, which is why local builds passed and
masked the issue.

Declare @tailwindcss/postcss as a direct devDependency of the three apps that run
Vite/PostCSS so it is symlinked into each app's node_modules and resolves under the
isolated linker regardless of install flow.

Verified by reproducing the exact Docker flow (prune -> fetch -> --frozen-lockfile
offline install -> build) for admin, space and web: all install in sync and build
successfully with full Tailwind CSS output.
2026-06-01 16:44:16 +05:30
Rahul Cheryala bd0d164e0b fix(GIT-235): add styles to onboarding tour close button for contrast (#9188) 2026-06-01 16:43:45 +05:30
sriram veeraghanta 011328c793 [GIT-213] fix: return HTTP response from dispatch() exception handler (#9179)
* fix(api): return HTTP response from dispatch() exception handler

BaseAPIView.dispatch() and BaseViewSet.dispatch() built the proper
error Response via handle_exception() but returned the raw exception
object instead, causing Django to raise
"TypeError: 'Exception' object is not a valid HTTP response".

Fix all six occurrences across the api, app, license and space view
bases, and add a regression test covering every affected base class.

Fixes #9157

* chore(api): add copyright header to tests/unit/views/__init__.py

The empty package init file was missing the AGPL copyright header,
failing the Copy Right Check CI (addlicense -check on all tracked
.py files).
2026-06-01 15:03:22 +05:30
sriram veeraghanta 3f57fefdb4 chore: move all dependencies into pnpm catalog (#9153)
Centralize every external dependency version in the pnpm catalog
(pnpm-workspace.yaml) and reference them via `catalog:` across all
apps and packages. Packages that previously used differing versions
were unified to the highest (notably @react-pdf/renderer ^3.4.5 ->
^4.3.0 in apps/web).
2026-05-31 15:56:12 +05:30
sriram veeraghanta 04622ce118 fix: harden webhook/link/OAuth-avatar SSRF (advisory clusters A/B/C/E) (#9163)
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)

Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:

- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
  handler passed context={request: request} (the request object as the
  dict key) so the loopback/disallowed-domain guard silently no-op'd —
  now context={"request": request}. Hardened IP classification
  (is_blocked_ip) to also block multicast, unspecified, CGNAT
  (100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
  (IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.

- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
  requests resolved it again at connect time. New pinned-IP client
  (plane/utils/url_security.py) resolves+validates once and connects to
  the validated IP literal so urllib3 performs no second lookup, while
  preserving Host header, TLS SNI and certificate verification against
  the real hostname.

- Cluster C (redirect SSRF): webhook delivery never follows redirects;
  the link crawler follows them manually, re-resolving + re-validating +
  re-pinning every hop.

Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.

Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.

* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests

Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.

The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.

Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.

docker compose test: 199 unit tests pass.

* fix(api): address PR review feedback on the SSRF pinned client

- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
  Auth instead of silently dropping them when rewriting to the IP literal
  (Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
  add stream=True support that keeps the session open until the response is
  closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
  getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
  actually bounds memory, upload the size-bounded buffer (not response.content),
  and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
  streamed session lifetime; drop an unused import.

docker compose test: 204 unit tests pass.
2026-05-31 00:12:23 +05:30
sriram veeraghanta 248f5d66e6 refactor(api): source API_KEY_RATE_LIMIT from settings, drop service token throttle (#9161)
- Define API_KEY_RATE_LIMIT in plane/settings/common.py and read it via
  django.conf.settings in ApiKeyRateThrottle instead of os.environ.
- Remove ServiceTokenRateThrottle and the service-token branch in
  BaseAPIView.get_throttles; all API key requests now go through
  ApiKeyRateThrottle.
2026-05-29 00:13:41 +05:30
KanteshMurade f14451a5de fix(web): add Safari fallback for requestIdleCallback (#9137)
* fix(web): add Safari fallback for requestIdleCallback

* fix(web): use globalThis in idle-task fallbacks

Switch idle-task fallback paths from window.* to globalThis.* so the
fallback no longer crashes in environments where window is undefined.
Also thread IdleRequestOptions through requestIdleFallback so the
caller's timeout hint is honored when falling back.

Addresses CodeRabbit review feedback on #9137.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2026-05-28 23:56:46 +05:30
Manish Gupta 095b1aa360 [WEB-7447] feat: migrate CE telemetry from OTLP traces to OTLP metrics (#9156)
* [WEB-7447] feat: migrate CE telemetry from OTLP traces to OTLP metrics

Replace span-based tracing (tracer.py) with OTLP observable gauges,
mirroring the approach already used in plane-ee. Key changes:

- Add otlp_endpoints.py — shared gRPC/HTTP endpoint helpers
- Add telemetry_metrics.py — push_instance_metrics task using
  MeterProvider + observable gauges (service name: plane-ce-api)
- User count excludes bots (is_bot=False)
- Page count excludes bot-owned private pages only
- Domain derived from WEB_URL env var
- Celery beat entry replaced with timedelta schedule +
  configurable METRICS_PUSH_INTERVAL_MINUTES (default 360 min)
- Add explicit opentelemetry-exporter-otlp-proto-grpc dep
- Delete tracer.py and telemetry.py (no longer needed)

Co-authored-by: Plane AI <noreply@plane.so>

* fix: address review comments on CE telemetry metrics

- harden grpc_endpoint_from_url for scheme-less OTLP_ENDPOINT values
  (e.g. "telemetry.plane.so:4317") by prepending "//" before urlparse
- fix WEB_URL domain extraction for scheme-less values with same approach
- replace N+1 workspace count queries (6×N) with 6 batched annotate(Count)
  aggregation queries — reduces DB load significantly at WORKSPACE_METRICS_LIMIT
- add deterministic ordering (order_by created_at) to workspace slice
- harden METRICS_PUSH_INTERVAL_MINUTES env parsing with try/except guard
  and positive-value validation to avoid crash on malformed input

Co-authored-by: Plane AI <noreply@plane.so>

* fix: cap METRICS_PUSH_INTERVAL_MINUTES to prevent timedelta overflow

Add upper-bound check (10_000_000 minutes) and catch OverflowError alongside
ValueError so an arbitrarily large env value cannot crash worker startup via
timedelta(minutes=...) OverflowError.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-05-28 18:34:27 +05:30
sriram veeraghanta 0acb32e65e chore: bump turbo to 2.9.14, migrate pnpm config to workspace yaml (#9147)
* chore: bump turbo to 2.9.14, migrate pnpm config to workspace yaml

- Bump turbo from 2.9.4 to 2.9.14 in root package.json and the
  four production Dockerfiles (web, live, admin, space).
- Move pnpm.overrides, onlyBuiltDependencies, and
  ignoredBuiltDependencies from package.json into pnpm-workspace.yaml.
  pnpm v10+ no longer reads the pnpm field in package.json, so the
  full overrides block and most of onlyBuiltDependencies were being
  silently ignored.
- Add @plane/utils as a workspace dependency to the live server.

* chore: drop unused allowBuilds block, bump lodash-es to 4.18.1

- Remove the `allowBuilds` block from pnpm-workspace.yaml. It is not
  a recognized pnpm v10/v11 key and its values were inconsistent with
  the actual `onlyBuiltDependencies` / `ignoredBuiltDependencies`
  configuration.
- Bump `lodash-es` catalog entry from 4.18.0 to 4.18.1. With overrides
  now applied workspace-wide, 4.18.0 (marked deprecated as a "bad
  release") was being enforced everywhere.

* fix: use pnpm v11 allowBuilds in place of removed legacy keys

`onlyBuiltDependencies` and `ignoredBuiltDependencies` were removed
in pnpm v11. They were being silently ignored on this branch, which
caused `ERR_PNPM_IGNORED_BUILDS` to fail CI under `--frozen-lockfile`.

Replace them with the v11-native `allowBuilds:` block, mapping the
previous allowlist to `true` and the previous denylist (sharp) to
`false`. Locally verified that the build scripts for @parcel/watcher,
@swc/core, esbuild, and msgpackr-extract now run on install.
2026-05-27 16:06:15 +05:30
sriram veeraghanta edf2475413 refactor: logging with retention + API token hardening (#9148)
* fix: harden API token handling against rate-limit tampering and plaintext logging

- Make `allowed_rate_limit` read-only on APITokenSerializer so users can no
  longer raise their own API token rate limit via PATCH (GHSA-xfgr-2x3f-g2cf).
- Stop persisting API keys in plaintext in APITokenLogMiddleware: store a
  SHA-256 hash as the token identifier and redact sensitive request headers
  (X-Api-Key, Authorization, Cookie) before logging (GHSA-r5p8-cj3q-38cc).

* refactor: remove MongoDB log sink and add per-log-type retention

Logs are now written to and cleared from PostgreSQL only; MongoDB is no
longer used as a log sink or archive.

- Drop the MongoDB write/archival paths from the API request logger, the
  webhook log writer, and the cleanup tasks; Postgres is the sole sink.
- Cleanup tasks now hard-delete expired rows in batches via `all_objects`
  (rows are removed immediately, not soft-deleted).
- Add env-backed, per-log-type retention settings: API activity logs
  (API_ACTIVITY_LOG_RETENTION_DAYS, default 14), webhook logs
  (WEBHOOK_LOG_RETENTION_DAYS, default 14), email logs
  (EMAIL_LOG_RETENTION_DAYS, default 7). HARD_DELETE_AFTER_DAYS no longer
  drives any log cleanup.
- Delete settings/mongo.py, remove MONGO_DB_* settings and the plane.mongo
  loggers, and drop the pymongo dependency.

* chore: gitignore local advisories.md notes file

* fix: use keyed HMAC-SHA256 for API token log identifier

Address CodeQL "weak hashing of sensitive data" by hashing the API key with
a SECRET_KEY-keyed HMAC instead of a bare SHA-256. The identifier is a
non-reversible tokenization of a high-entropy key (not password storage);
keying it also prevents precomputing the digest from a known key value.

* chore: address review feedback on log cleanup and request logging

- process_logs accepts extra kwargs so jobs enqueued by an older release
  (with a mongo_log arg) don't fail during a rolling deploy.
- Log-cleanup batch delete failures are logged and skipped rather than
  aborting the run, so a single bad batch can't block the rest.
- Extend logger middleware test to assert Authorization and Cookie headers
  are redacted; add a test that a failing cleanup batch is swallowed.

* fix: fall back to default when a log retention env value is invalid

Negative (or unparseable) retention values would compute a future cutoff and
delete every log row. The retention settings now fall back to their defaults
in that case via a shared `_retention_days` helper.
2026-05-27 16:00:05 +05:30
sriram veeraghanta 310d2eda21 chore: restructure .claude/skills into per-skill directories (#9146)
- Replace flat pr-description.md / release-notes.md with per-skill folders
- Add new branch-name and translate skills
- Update release-notes skill to match the GitHub Releases format (v1.2.0)
2026-05-26 22:25:56 +05:30
pratapalakshmi 13a3ea27fb fix: security vulnerabilities for plane docker images (#9140) 2026-05-26 14:25:01 +05:30
sriram veeraghanta 9f77ea5ebb fix: Add docker pytest runner and fix bugs the suite surfaced (#9138)
* chore(api): add docker compose test runner

Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.

Prereq: ./setup.sh (generates apps/api/.env).

Usage:
  docker compose -f docker-compose-test.yml up --build \
    --abort-on-container-exit --exit-code-from api-tests
  docker compose -f docker-compose-test.yml down -v

* fix(api): correct bugs surfaced by the pytest suite

Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:

- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
  required project_id in the request body, but the view only ever passes
  it through the URL kwarg. Cycle create/update via the public API was
  returning 400 "Project ID is required". Read project_id from
  serializer context (passed by the view) in addition to body/instance.

- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
  out is_service=True tokens, so a user could read and modify service
  tokens through the user token endpoint. The list mode and delete
  already filter is_service=False; aligned the other two.

- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
  scheme, so file:///etc/passwd raised "No hostname found" instead of
  the documented "Only HTTP and HTTPS" error. Swapped the order so the
  scheme guard matches the docstring intent.

- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
  so when both are configured to different hosts (the standard local
  setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
  allow-list. Redirects to APP_BASE_URL then had their next_path stripped
  because the host wasn't allowed. Include every configured base URL.

* chore(api): align pytest tests with current behavior, clear warnings

Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.

Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
  the Project model defaults the flag to False, so cycle create/update
  always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
  (validate_next_path rejects values without a leading slash and returns
  empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
  now returns description_json; the task unconditionally writes the value
  back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
  char 970+ on a single line, which contains_url truncates away as ReDoS
  defense (500-char per-line cap). Restructured to keep test intent intact
  while staying inside the per-line window.

Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
  removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
  moved jsonlogger → json; update the import / formatter path.
2026-05-26 01:21:37 +05:30
Sangeetha e71a8f5dbb [GIT-174]chore: set completed_at as read only field for work item (#9083) 2026-05-25 14:01:50 +05:30
sriram veeraghanta 41b03bb142 Merge commit from fork
The webhook dispatcher validated webhook.url before posting but called
requests.post() without allow_redirects=False, so a webhook destination
could return a 3xx redirect to an internal address (cloud metadata,
internal services) and have the worker fetch it and persist the
response body to webhook_logs, readable back via the webhook-logs API.

Pass allow_redirects=False so the original validate_url() guard is
authoritative. Matches the pattern already used by safe_get() in
work_item_link_task.py and the behavior of GitHub/Stripe/Slack webhooks.
2026-05-25 13:59:04 +05:30
bubacho fd613dc738 fix(web): add requestIdleCallback fallback for Safari/iOS (#9094)
* fix(web): fallback when requestIdleCallback is unavailable

* refactor: improve idle task scheduling safety in render-if-visible
2026-05-25 01:01:11 +05:30
astarte75 039d582fbb fix(aio): use JSON array double quotes in VOLUME instruction (#9099)
The community AIO Dockerfile declared the VOLUME instruction with
single quotes: VOLUME ['/app/data', '/app/logs']. Docker's JSON (exec)
form requires double quotes; with single quotes the line is parsed as
the shell form and the bracket/comma tokens become literal volume
paths ('[/app/data,' and '/app/logs]').

Docker tolerated these non-absolute anonymous volume paths at container
create time until Engine 29.5.0, which now rejects them with
"invalid mount config for type volume: invalid mount path: '[/app/data,'
mount path must be absolute", breaking `docker compose up --force-recreate`
and any container recreation for the AIO community image.

Switching to the valid JSON array form fixes the parsing.
2026-05-21 16:54:57 +05:30
b-saikrishnakanth 4ca6d6c7b8 [WEB-7182] fix: remove profile preferences activity (#9025) 2026-05-19 15:42:28 +05:30
b-saikrishnakanth 208f35964b [WEB-7181] fix: empty comment quick-actions menu in work item activity (#9024)
* fix: comment quick-actions menu hidden when no actions are available

* refactor: remove dead code
2026-05-19 15:42:11 +05:30
jamartineztelecoengineer84-dotcom 50a7b47b31 fix(api): pass project_lead_id (not User instance) when creating ProjectMember (#8966)
* test(api): add regression tests for create-project endpoint

Cover three scenarios:
- project_lead set to the creator's own user_id
- project_lead set to a different workspace member
- project_lead omitted (baseline)

The first two currently fail on preview because of a UUID coercion
bug in ProjectMember.objects.create — see follow-up commit.

* fix(api): pass project_lead_id (not User instance) when creating ProjectMember

The create-project endpoint built a ProjectMember row with
member_id=serializer.instance.project_lead, which resolves to a User
instance via Django's related descriptor instead of a UUID. Django's
UUIDField coercion then fails with AttributeError: 'User' object has
no attribute 'replace', which the generic exception handler converts
to a 400 "Please provide valid detail" — but only after the Project
row was already persisted, leaving an orphaned project without
default states.

Fix:
- Use project_lead_id (FK ID, no descriptor lookup) on both the guard
  comparison and the ProjectMember creation.
- Wrap the post-save flow in transaction.atomic() so any future
  exception triggers a clean rollback.
- Defer model_activity.delay() with transaction.on_commit() so the
  activity log only fires after a successful commit.
- Capture the exception with log_exception() in the generic catch so
  future regressions surface in api logs.

Note: a related data integrity issue exists where
ProjectCreateSerializer doesn't create a ProjectIdentifier row
(unlike its frontend counterpart). Out of scope here, will follow
up in a separate PR.

* fix(api): return 500 on unexpected errors and harden project create

Address review feedback from @sriramveeraghanta on PR #8966:

- The catch-all `except Exception` now returns 500 instead of 400.
  Reusing the generic 400 response on a server-side crash was the
  anti-pattern that hid the original ghost-create bug for nine months;
  a 500 lets clients distinguish between "bad input" and "server fault".
- The `IntegrityError` branch no longer falls through silently when the
  message is unrecognised. It re-raises so the catch-all `except` logs
  the exception and returns a 500.
- `transaction.on_commit()` now schedules `model_activity.delay` via
  `functools.partial` instead of a lambda, avoiding late-binding closure
  semantics.
- `ProjectCreateSerializer.validate()` now rejects `project_lead`
  values that are not active workspace members, surfacing the error
  under the `project_lead` field key (rather than as `non_field_errors`)
  so API clients can react programmatically.

* test(api): harden assertions and cover rollback / workspace-membership

Address review feedback from @sriramveeraghanta on PR #8966:

- The three existing tests now look up the created project via
  `Project.objects.get(id=response.data["id"])` instead of
  `.first()`. The assertion now fails for the right reason if the
  wrong project is returned by the endpoint.
- New `test_create_project_with_lead_not_in_workspace_returns_400`
  guards the workspace-membership validation added to
  `ProjectCreateSerializer.validate()`. Expects a 400 with a
  field-shaped error and zero rows persisted.
- New `test_model_activity_not_called_on_rollback` locks in the
  `transaction.on_commit()` semantics: when an exception is raised
  inside the atomic block (forced via mocking `State.objects.bulk_create`),
  the response is 500, no Project / ProjectMember / State rows are
  persisted, and the deferred `model_activity.delay` task is never
  dispatched. This prevents a future refactor from silently
  regressing the rollback contract.

* fix(api): mark on_commit dispatch as robust against broker failures

Address coderabbit re-review feedback on PR #8966.

Without robust=True, an exception raised by model_activity.delay
(e.g., a Celery broker outage) propagates out of the on_commit
callback and is caught by the outer `except Exception` handler,
which returns a 500 despite the project, ProjectMember rows and
default States having already been committed. The client sees a
500 and assumes the create failed — the same class of mismatch
between actual state and reported status that the original bug
exhibited, just at the post-commit phase.

Set robust=True so Django logs the dispatch failure internally
via the standard transaction logger and the response stays 201,
reflecting the persisted state.

Switch from `functools.partial` to a nested function
(`_dispatch_model_activity`) for the on_commit callable. Django's
robust on_commit logging path reads `func.__qualname__` to format
the error message; `partial` objects lack that dunder by default,
and the `functools.update_wrapper` workaround turns out to be
brittle when the wrapped callable is replaced by a Mock (which
the new regression test relies on). A nested function exposes
`__qualname__` natively, and the locals it closes over are
bound at definition time and never rebound before the callback
fires, so the late-binding-closure motivation for `partial` over
`lambda` does not apply here.

A new test, test_response_still_201_when_broker_dispatch_fails,
mirrors test_model_activity_not_called_on_rollback to lock in the
post-commit branch. It uses `@pytest.mark.django_db(transaction=True)`
so the surrounding test transaction is actually committed and the
`on_commit` callback fires (the default wrapper suppresses it via
rollback).

* fix(api): handle unrecognised IntegrityError consistently

Address coderabbit re-review feedback on PR #8966.

The previous fix used `raise` inside the IntegrityError handler with
the intent of "letting the catch-all `except Exception` below log it
and return 500". Coderabbit correctly flagged that `raise` exits the
try/except entirely — sibling except clauses don't fire — so
unrecognised integrity errors actually skipped `log_exception` and
the consistent 500 JSON shape, contradicting the stated intent.

Replicate the catch-all behaviour inline: log the exception via
`log_exception(e)` and return the same generic 500 response with
`{"error": "An unexpected error occurred"}`. The client now gets a
uniform error shape regardless of which `except` branch handled it.

---------

Co-authored-by: Jose Antonio Martinez <257598434+jamartineztelecoengineer84-dotcom@users.noreply.github.com>
2026-05-15 02:07:32 +05:30
sriram veeraghanta 65d6a94b0a refactor(i18n): migrate packages/i18n from MobX to react-i18next (#8898)
* refactor(i18n): migrate packages/i18n from MobX to react-i18next with per-feature namespaces

Replaces the internals of packages/i18n with react-i18next while preserving the
identical public API. Consumer code using useTranslation() and TranslationProvider
requires no changes.

Translation file format: TS objects to JSON namespaces
- Converted TypeScript translation files (19 languages) into feature-based JSON namespace files
- Split the monolithic translations.ts into per-feature namespace files: workspace.json,
  project.json, work-item.json, cycle.json, inbox.json, etc.
- 30 community namespaces across 19 languages = 570 JSON files

Core runtime: MobX to i18next
- Replaced MobX TranslationStore with an i18next instance using i18next-icu
  (preserves ICU MessageFormat) and i18next-resources-to-backend (namespace lazy loading)
- useTranslation() and TranslationProvider keep identical signatures
- All namespaces pre-loaded during init for the current language to prevent
  re-render cascades
- Reads saved language from localStorage before init for faster first paint

Build tooling
- scripts/generate-types.ts: Reads English JSON files and outputs keys.generated.ts
  with a flat union of translation keys (runs before every build)
- scripts/sync-check.ts: Cross-locale missing/stale key detection, cross-namespace
  collision detection, path conflict detection (supports --ci mode)

App-level changes
- Removed useTranslation-based language sync effect from store-wrapper
- Language is now synced imperatively from profile.store (fetchUserProfile,
  updateUserProfile) and root.store (resetOnSignOut) via setLanguage()

Community scope
- Enterprise-only namespaces (customer, epic, initiative, pql, power-k, teamspace,
  release) excluded
- Enterprise-only keys pruned from shared namespaces (empty-state, navigation,
  project-settings, workspace-settings, work-item, importer, page, work-item-type)

* fix(i18n): restore parity with community preview after namespace refactor

The community port of plane-ee#6449 (MobX -> react-i18next refactor) had
gaps that broke ~25 unique translation keys community code calls. This
commit restores parity:

- Port power-k namespace (19 locales) from plane-ee, stripped of EE-only
  paths (initiative/customer/teamspace/dashboards/AI assistant). Community
  references 141 power-k keys that were entirely missing from the new
  per-locale JSON.
- Restore epic.* keys (8 leaves) into work-item.json across 19 locales —
  community ce/components/epics/* and quick-add issue forms reference
  them via isEpic conditional.
- Add 'date' leaf to common.json across 19 locales (sourced from
  work_item_types.settings.properties.property_type.date.label so the
  proper translation, not English, is used).
- Move exporter.* subtree from importer.json to common.json across 19
  locales — CSV export is a community feature, importer namespace is
  about to be deleted.
- Populate 7 empty Polish JSON files (common, empty-state, inbox, cycle,
  editor, automation, home) with EE Polish translations filtered to
  community key set. The community port committed these as 0-byte files.
- Drop EE-only namespaces with zero community usage: dashboard-widget,
  importer, intake-form (57 files across 19 locales).
- Update NAMESPACES const: drop the 3 deleted namespaces, add power-k.
- Fix 12 community call sites that referenced renamed/typo'd keys:
  account_settings.api_tokens.heading -> .title
  auth.common.password.toast.error.* -> .change_password.error.*
  sign_out.toast.error.* -> auth.sign_out.toast.error.*
  notification.toasts.un_snoozed -> .unsnoozed
  profile.stats.priority_distribution.priority -> common.priority
  projects.label -> common.projects
  progress -> common.progress
  epics -> common.epics
  creating_theme -> common.saving (no localized source available)
  toast.error (with trailing space typo) -> toast.error

Verified: every literal t(...) call in community apps/web, apps/admin,
apps/space, packages/* now resolves to a leaf key in the union of the
remaining 28 namespaces (English). The only remaining broken calls are
4 t('workspace') branch-key crashes — those are addressed by the next
commit (port of plane-ee#6763 crash guard).

Refs: makeplane/plane-ee#6449

* fix(i18n): guard t() against namespace-node returns to prevent React crashes

Wraps useTranslation()'s t() in coerceToString so namespace-node lookups
(which i18next-icu unconditionally returns as raw objects regardless of
returnObjects:false) fall back to the key string instead of crashing
React with 'Objects are not valid as a React child'.

Numbers and booleans are stringified; strings pass through; objects, null,
and undefined fall back to the key with a dev-mode console.warn pointing
to the bad call site. Production builds suppress the warning but keep the
guard. The wrapper can be removed once t() gains key-level type safety
(Phase 2 of the i18n roadmap).

Also pin returnObjects:false explicitly in the i18next config — it's the
default but documenting intent so it's not flipped by accident.

Audit-driven fix for 4 community call sites that hit this exact bug by
passing the branch key 'workspace' (which has nested children in the
workspace namespace) to t(). Switched to t('common.workspace') (existing
leaf with value 'Workspace').

Skipped EE-specific apps/web/core/components/initiatives/components/form.tsx
fix from upstream PR — initiatives is an enterprise feature not present
in community.

Refs: makeplane/plane-ee#6763

* chore(i18n): gitignore auto-generated translation key types

keys.generated.ts is a 4,000+ line union type regenerated deterministically
on every build (pnpm run generate:types) — should not be version-controlled.

Adding the file to .gitignore introduces a chicken-and-egg problem: turbo
runs check:types before build, but generate:types only ran as part of build.
On a fresh clone with no keys.generated.ts present, tsc --noEmit fails. Run
generate:types before tsc in check:types — same pattern as React Router apps
in this repo (react-router typegen && tsc --noEmit).

- Add packages/i18n/src/types/keys.generated.ts to root .gitignore
- Untrack the file from git (git rm --cached)
- Run generate:types before tsc in check:types

Verified: deleting keys.generated.ts and running check:types regenerates
the file correctly. After regeneration, git status shows the file remains
untracked (.gitignore is honored).

Refs: makeplane/plane-ee#6784

* fix(i18n): translate settings sidebar category headers

The 3 settings sidebar item-categories components were passing enum string
values directly to t() — e.g. t('your profile'), t('work-structure'),
t('administration'). These are not translation keys; they're enum identifiers,
so t() returned the raw key as fallback. Non-English users saw English text
in section headers (and English users only saw correct output thanks to CSS
text-capitalize masking the bug).

Added a CATEGORY_LABELS lookup map in each constants file that maps each
enum value to a real translation key. Components now call t(LABELS[category])
instead of t(category).

- Added 5 new keys to en/common.json common.* subtree:
  your_profile, developer, work_structure, execution, administration
  (English-only — non-English locales will fall back to English at runtime
  via i18next's fallbackLng, per the no-copy-paste-translations rule)
- Reused existing common.general and common.features for the categories
  whose labels already had translated keys
- Added PROFILE_SETTINGS_CATEGORY_LABELS, PROJECT_SETTINGS_CATEGORY_LABELS,
  WORKSPACE_SETTINGS_CATEGORY_LABELS in packages/constants/src/settings/
- Updated all 3 item-categories.tsx components

Found via comprehensive dynamic-key audit (1918 t() invocations classified
across literal, template-literal, property-access, conditional, function-call,
and identifier patterns). Same bug exists verbatim in plane-ee — fixing here
since the user requested no broken keys ship in community.

* chore: untrack Claude Code runtime lockfile

.claude/scheduled_tasks.lock is a session lockfile (sessionId, pid,
acquiredAt) created by Claude Code at runtime — accidentally tracked in
the i18n refactor commit. Untrack from git; the file stays on disk for
the running session.

* fix(i18n): type-safe coerceToString call + bump lint ceiling

Two post-Commit D follow-ups:

- Fix TS2379 in use-translation.ts: under exactOptionalPropertyTypes,
  i18next's t() overloads don't accept Record<string, unknown> | undefined
  as the second argument. Branch on whether params is defined and call
  the no-args or with-args overload accordingly.

- Bump @plane/i18n check:lint --max-warnings from 2 to 9. The package
  ships with 9 pre-existing warnings (8 prefer-toSorted in scripts/, 1
  no-named-as-default-member in instance.ts on a line untouched by my
  changes). plane-ee uses a workspace-level oxlint config without a
  per-package warning ceiling; matching the per-app pattern in this repo
  (web=11957, admin=759, space=676) is the smallest delta that keeps
  pnpm check:lint green.

Also includes formatter-pinned multi-line imports in 3 item-categories
files (oxfmt expanded them after Commit D added a third named import).

* fix(i18n): add packages/i18n/locales symlink to src/locales

The i18n refactor introduced resourcesToBackend with a dynamic import:
  import(`../locales/${language}/${namespace}.json`)

That path is relative to the source file's location. From src/core/instance.ts
it correctly resolves to src/locales/. But after tsdown bundling, the same
import call lives in dist/index.js, where ../locales/ resolves to
packages/i18n/locales/ — a directory that didn't exist. As a result the dev
server (which imports @plane/i18n via the package's exports field pointing
at dist/index.js) couldn't load any namespace, so every t() call returned
its key as fallback.

Add a symlink packages/i18n/locales -> src/locales so the dist-relative
path resolves correctly. Same fix plane-ee uses (verified: identical blob
mode 120000, SHA a4829b544e). Keeps tsdown.config.ts and package.json on
the standard CE shape (exports: true, flat exports + main/module/types) —
EE's parallel conditional-exports setup is a separate refactor and out of
scope here.

* refactor(i18n): sync non-English locales to 100% parity with English

- All 18 non-English locales filled to 3,837/3,837 keys against the
  canonical English source. Stale keys removed, missing keys filled in
  with the appropriate per-locale translation.
- New scripts/lib/locale-io.ts module shared between sync-check and
  future tooling. readJsonFile() wraps JSON.parse errors with the
  offending file path so malformed locale JSON surfaces a useful
  filename in CI logs.
- New .github/workflows/i18n-sync-check.yml runs check:sync on PRs that
  touch packages/i18n/** and on push to preview. Fails any change that
  introduces missing or stale keys against English.
- Pin tsx@4.20.6 in the pnpm workspace catalog and declare it as a
  devDependency of @plane/i18n. Replace npx tsx@4.19.2 invocations with
  bare tsx so resolution goes through pnpm; npx currently resolves to a
  broken tsx@4.21.0 that pulls an unpublished esbuild range.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2026-05-15 01:55:57 +05:30
sriram veeraghanta 7fd8e3364c Merge branch 'canary' of github.com:makeplane/plane into preview 2026-05-15 01:06:45 +05:30
Sangeetha 4225bc59de [GIT-175] fix: completed_at updation logic for work items (#9044)
* chore: update completed_at logic updation in Issue save method

* fix: update error handling

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: use StateGroup

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

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 13:39:54 +05:30
sriram veeraghanta 4c1bdd1d62 fix(api): use requester's workspace role for project member role updates (GHSA-x63v-p7wc-47x4) (#9014)
is_workspace_admin in ProjectMemberViewSet.partial_update was derived
from the target member's workspace role, not the requester's. When the
target happened to be a workspace admin, all three project-role guards
(L231/238/247) were bypassed regardless of who was making the request,
allowing a non-admin requester to re-role a workspace admin's project
membership. Compute is_workspace_admin from the requester instead and
keep the target's workspace role under a distinct name for the existing
new-role-vs-workspace-role cap.
2026-05-05 16:35:28 +05:30
MINIT ff21e53f5a fix(nginx): correct real_ip_header typo X-Forward-For → X-Forwarded-For (#8935)
X-Forward-For is not a real HTTP header — the standard is X-Forwarded-For.
With the typo, Nginx never replaces $remote_addr with the actual client IP,
so rate limiting and IP logging see the proxy IP instead of the real client.
Affects all three nginx configs (web, admin, space).
2026-05-05 13:51:12 +05:30
sriram veeraghanta 9491bdbe46 fix(api): scope cross-workspace resource lookups to prevent IDOR (#9008)
`ProjectViewSet.partial_update`, `BulkEstimatePointEndpoint.partial_update`,
and `WorkspaceUserProfileEndpoint.get` previously fetched objects by primary
key alone after a workspace-scoped permission check, allowing an authenticated
caller to act on resources belonging to other workspaces by supplying a
foreign UUID with their own workspace slug in the URL.

- Project partial_update: scope `Project.objects.get` by `workspace__slug`,
  matching the existing pattern in `destroy`.
- Bulk estimate partial_update: scope `Estimate.objects.get` by
  `workspace__slug` and `project_id`, matching `retrieve` and `destroy`.
- Workspace user profile: require the target `user_id` to be an active
  member of the requested workspace before returning email and other PII.
2026-05-04 17:58:28 +05:30
sriram veeraghanta a62fe8a781 chore(deps): remove unused pnpm overrides (#8973)
Drop four overrides that no package in the workspace depends on
(direct or transitive): js-yaml, happy-dom, tar-fs, and
@isaacs/brace-expansion. Verified against pnpm-lock.yaml — no resolved
entries existed, so the overrides were dead weight.
2026-04-29 18:26:56 +05:30
KanteshMurade db1c5b9513 fix: filter out soft-deleted states from API endpoints (#8840)
* fix: filter out soft-deleted states from API endpoints

- Add deleted_at__isnull=True filter to StateListCreateAPIEndpoint.get_queryset()
- Add deleted_at__isnull=True filter to StateDetailAPIEndpoint.get_queryset()
- Prevents soft-deleted states from reappearing in UI after navigation
- Fixes #8829

* Fix: exclude issues linked to soft-deleted states
2026-04-29 02:01:46 +05:30
dependabot[bot] a40e064448 chore(deps): bump postcss (#8931)
Bumps the npm_and_yarn group with 1 update in the /packages/tailwind-config directory: [postcss](https://github.com/postcss/postcss).


Updates `postcss` from 8.5.6 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  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-27 18:02:01 +05:30
778 changed files with 132224 additions and 60703 deletions
+67
View File
@@ -0,0 +1,67 @@
---
name: branch-name
description: Use when starting a new branch or renaming an existing one — produces a branch name in the format `<type>/<work-item-id>-<short-description>` that's compatible with the create-pr skill's work item ID extraction.
user_invocable: true
---
# Branch Naming
Create branch names that follow the convention `<type>/<work-item-id>-<short-description>`, where the work item ID can be cleanly extracted later (e.g., by the create-pr skill).
## Format
```
<type>/<work-item-id>-<short-description>
```
- All lowercase, hyphen-separated
- Work item ID stays in its original form but lowercased (e.g., `SILO-1146``silo-1146`)
- Short description is 25 words in kebab-case, focused on the _what_, not the _how_
## Workflow
1. **Determine the type** based on the work being done:
- `feat` — new functionality
- `fix` — bug fix
- `chore` — tooling, deps, config, non-user-facing housekeeping
- `refactor` — restructuring without behavior change
- `docs` — documentation only
- `perf` — performance improvement
2. **Determine the work item ID**:
- If the user gives one, use it
- If they reference a Plane work item (e.g., a URL or title), extract the ID
- If none exists, ask the user — don't invent one
3. **Write the short description**:
- 25 words in kebab-case
- Describe the outcome, not the implementation (`add-app-tile-visibility`, not `update-tile-component`)
- Skip filler words (`the`, `a`, `for`)
4. **Assemble and create the branch**:
```
git checkout -b <type>/<work-item-id-lowercased>-<short-description>
```
5. **Return the branch name** to the user.
## Examples
```
fix/silo-1146-relative-config-urls
feat/web-1234-app-tile-visibility
chore/web-2201-bump-eslint
refactor/silo-980-extract-auth-middleware
docs/web-1500-pr-template-update
perf/silo-1310-cache-workspace-lookup
```
## Common Mistakes
- Putting the work item ID at the end instead of after the type (breaks extraction)
- Using underscores or camelCase instead of hyphens
- Uppercasing the work item ID inside the branch name (it should be lowercase here, uppercased only when used as the PR title prefix)
- Writing a long, narrative description — keep it scannable
- Omitting the work item ID when one exists in Plane
- Using a type that won't match the eventual PR type (pick the type you'd use in the PR title)
@@ -0,0 +1,65 @@
---
name: create-pull-request
description: Use when creating a pull request for the current branch — gathers branch context, generates a PR description following the repo's pull_request_template.md, and creates the PR with a Plane work item ID prefix in the title.
user_invocable: true
---
# Create PR
Create a pull request using the repo's PR template, a Plane work item ID as the title prefix, and a fully filled-out description based on the actual diff.
## Workflow
1. **Determine the base branch**: Default to `preview` unless the user specifies otherwise.
2. **Gather context** (in parallel):
- `git status -s` — check for uncommitted changes
- `git diff <base>...HEAD --stat` — files changed
- `git log <base>...HEAD --oneline` — all commits on the branch
- `git diff <base>...HEAD --no-color` — full diff for understanding changes (if very large, focus on the most important files first)
- `git rev-parse --abbrev-ref --symbolic-full-name @{u}` — check if branch tracks a remote
- Read `.github/pull_request_template.md` from the repo root
3. **Determine work item ID**:
- Extract from branch name if it contains an identifier (e.g., `chore/silo-1146-foo``SILO-1146`, `feat/web-1234-x``WEB-1234`)
- If not found in branch name, ask the user
4. **Draft the PR** using the template from step 2:
**Title**: `[WORK-ITEM-ID] <type>: <concise summary>` (under 70 chars)
- Type reflects the change: `fix`, `feat`, `chore`, `refactor`, `docs`, `perf`, etc.
**Body**: Fill in every section from the PR template based on the actual diff:
- **Description** — Clear, concise summary of what the PR does and why. Focus on the "what" and "why", not line-by-line changes. Mention important implementation decisions.
- **Type of Change** — Check the appropriate box(es): Bug fix, Feature, Improvement, Code refactoring, Performance improvements, Documentation update.
- **Screenshots and Media** — Leave a placeholder: `<!-- Add screenshots here -->`
- **Test Scenarios** — Suggest concrete scenarios grounded in the actual changes (e.g., "Navigate to project settings and verify the new toggle works"), not generic ones.
- **References** — Include the work item ID, any linked issues the user mentions, and any Sentry issue links/IDs (e.g., `SENTRY-ABC123` or Sentry URLs) referenced earlier in the conversation.
Append a Claude Code session line at the bottom of the body.
5. **Push and create** (in parallel where possible):
- Push branch with `-u` if no upstream is set
- Create PR via `gh pr create` using a HEREDOC for the body
6. **Return the PR URL** to the user.
## Example Title
```
[SILO-1146] fix: allow relative URLs for configuration_url and improve app tile visibility
```
## Guidelines
- Keep the description concise but informative
- Use bullet points when listing multiple changes
- Focus on user-facing impact, not implementation details
- Don't fabricate test scenarios that aren't relevant to the actual changes
## Common Mistakes
- Summarizing only the latest commit instead of all commits on the branch
- Forgetting to check for an upstream before pushing
- Using a work item ID format that doesn't match the branch convention
- Wrapping the PR body in a code fence when passing it to `gh pr create`
-58
View File
@@ -1,58 +0,0 @@
---
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
@@ -1,147 +0,0 @@
---
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
+200
View File
@@ -0,0 +1,200 @@
---
name: release-notes
description: "Generate release notes for a Plane release PR in either `makeplane/plane-cloud` (date-based versioning, e.g. `release: vYY.MM.DD-N`) or `makeplane/plane-ee` (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 in the GitHub Releases format."
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. Output matches the format used on `github.com/makeplane/plane/releases` (e.g. [v1.2.0](https://github.com/makeplane/plane/releases/tag/v1.2.0)). Works for both `makeplane/plane-cloud` and `makeplane/plane-ee`.
## Repo-specific versioning
Plane uses **different version schemes** across its two release repos. Detect which repo the PR belongs to and use the matching scheme when communicating about the release — the version itself does **not** appear in the release notes body (GitHub's release tag carries it).
| Repo | Version scheme | Example PR title | Source branch | Target branch |
| ----------------------- | -------------- | ---------------------- | ------------- | -------------------- |
| `makeplane/plane-cloud` | Date-based | `release: v26.04.13-1` | `uat` | `master` |
| `makeplane/plane-ee` | Semver | `release: v1.12.0` | `uat` | `master` / `preview` |
- **plane-cloud** ships daily — version is `vYY.MM.DD-N` where `N` is the counter for that date's release.
- **plane-ee** ships on a versioned cadence — version is `vX.Y.Z` (major.minor.patch) following semver.
- Detect the repo with `gh pr view <PR_NUM> --json headRepository,baseRepository` or from the URL the user shared.
## When to Use
- User links/mentions a Plane release PR (e.g. `release: v26.04.13-1` for cloud or `release: v1.12.0` for EE) and asks for release notes
- User asks to "create release notes" / "update PR description" for a PR in `makeplane/plane-cloud` or `makeplane/plane-ee`
- The branch is named `uat` or `release/x.y.z` and the base is `master` or `preview`
## 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 |
| -------------------------------------------- | ------------------------------------- |
| `Sync: Enterprise Changes #NNNN` | Cross-repo sync, no functional change |
| `fix: merge conflicts` | Merge artifact |
| `Merge branch '...' of github.com:...` | Merge artifact |
| `Revert "..."` (when immediately re-applied) | Internal churn |
### 3. Identify work item IDs (for research only)
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.
**Do not include these IDs in the release notes.** The GitHub Releases format is end-user-facing — IDs are only useful as a lookup key for fetching context in step 4.
### 4. (Optional) Enrich via Plane MCP
For larger features where the commit headline is terse, fetch the work item to write a richer paragraph:
```
mcp__plane__retrieve_work_item_by_identifier(project_identifier="WEB", issue_identifier=6874)
```
Use the returned `name` and `description_stripped` to flesh out the prose. Skip for routine fixes — commit body is usually enough. Don't enrich every item (slow + descriptions are often empty).
### 5. Categorize commits
Map each surviving commit into one of four sections:
| Commit signal | Section |
| --------------------------------------------------------------------------------------------------------------------- | --------------- |
| `feat:` that introduces a brand-new screen, flow, or capability | ✨ Features |
| `feat:` that improves an existing feature, plus most `refactor:` and behavioural `chore:` items that are user-visible | ⬆️ Enhancements |
| `fix:`, `fix(scope):` | 🐞 Bug fixes |
| CVE upgrades, dependency bumps that close a vulnerability, security hardening | 🛡️ Security |
**Drop entirely** (do not surface to users): pure infra `chore:`, dependabot bumps with no CVE, internal refactors with no behavioural impact, test-only changes, doc-only changes.
### 6. Format
Output follows the GitHub Releases convention — `###` for section headers, with two spaces between the emoji and the label for ✨ / ⬆️ / 🐞 (matches `v1.2.0`).
```markdown
### ✨ Features
#### **Short Feature Name in Title Case**
A 13 sentence paragraph describing what the user gets, why it matters, and any notable behaviour. Write in product-marketing voice, not commit-message voice.
- Optional nested bullets for sub-capabilities or callouts
- Keep them user-facing — what the user can now do
#### **Second Major Feature**
Another descriptive paragraph. Each major feature gets its own `####` subsection.
### ⬆️ Enhancements
- One-line description of an improvement to an existing capability
- Another improvement, written as a clean sentence (no commit prefix, no ticket ID)
### 🐞 Bug fixes
- Plain-English description of what was broken and is now fixed
- Another bug fix
### 🛡️ Security
- Upgraded <component> to <version> to mitigate [CVE-XXXX-NNNNN](https://link-to-advisory). Brief impact note.
- Other security-relevant change
```
Rules:
- Section headers use `###` (three hashes), then emoji + **two spaces** + label — exactly as in the published v1.2.0 release. Exception: 🛡️ Security uses a single space (matches v1.2.0).
- Features use `####` (four hashes) and the feature name is **bolded** inside the heading: `#### **Feature Name**`.
- Each feature gets a real paragraph, not a bullet — written for end users, not engineers.
- Enhancements, Bug fixes, and Security are simple bullets. No nested asterisks, no ticket IDs, no PR numbers.
- **Do not include work item IDs (`[WEB-XXXX]`) or PR numbers (`(#NNNN)`)** in any section — this format is user-facing.
- **Do not add a `# Release vX.Y.Z` heading.** The GitHub release tag carries the version; the body starts directly with the first `### ✨ Features` section.
- **Do not insert images.** The user adds screenshots manually after the notes are drafted. Leave space for them only if the user asks.
- Drop empty sections entirely.
- Blank line between section header and first bullet/feature, and between 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 into the four sections, draft notes
gh pr edit $PR --body "$(cat <<'EOF'
### ✨ Features
#### **...**
...
### ⬆️ Enhancements
- ...
### 🐞 Bug fixes
- ...
### 🛡️ Security
- ...
EOF
)"
```
## Reference example
The canonical target format is [v1.2.0](https://github.com/makeplane/plane/releases/tag/v1.2.0) on `makeplane/plane`. When in doubt about heading levels, spacing, bolding, or paragraph voice, match that page exactly (minus images).
## Common Mistakes
- **Including work item IDs in bullets** — the GitHub Releases format is user-facing; `[WEB-XXXX]` belongs in internal research, not the output.
- **Adding a `# Release vX.Y.Z` heading** — GitHub's release tag is the version. The body starts with `### ✨ Features`.
- **Copy-pasting commit subjects verbatim** — rewrite into product-marketing English. "fix: peek overview reload on parent add" → "Fixed peek overview reloading on adding a parent".
- **Bulleting features instead of writing paragraphs** — major features get `#### **Name**` plus a real paragraph; only enhancements/bugs/security use bullets.
- **Including `Sync: Enterprise Changes` commits** — these are sync PRs, never user-visible.
- **Including `fix: merge conflicts`** — merge artifact, no functional content.
- **Inserting images** — leave images for the user; they add screenshots manually.
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes.
- **Editing the PR title** — release PR titles are version markers; only edit the body.
- **Adding a Chores section** — the GitHub Releases format has no Chores section; user-invisible chores are dropped entirely.
## Plane-Specific Conventions
- Release PRs go from `uat``master` (or `preview`).
- PR title format:
- `plane-cloud`: `release: vYY.MM.DD-N` where N is the daily release counter for that date.
- `plane-ee`: `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 and almost always dropped from notes.
- `Sync: Enterprise Changes #NNNN` are automated cross-repo syncs and are _always_ skipped.
- CVE-related upgrades (NextJS, React, Django, nginx, etc.) belong under 🛡️ Security with a link to the advisory and a one-line impact note.
+608
View File
@@ -0,0 +1,608 @@
---
name: translate
description: Translate or update keys in packages/i18n/src/locales. Use whenever adding, changing, or reviewing strings across any target locale — enforces do-not-translate terminology, CLDR plural forms, placeholder/tag preservation, per-locale punctuation/register, and the AI-translation review workflow. Required reading before touching any *.json under src/locales.
user_invocable: true
---
<!--
`user_invocable: true` is advisory and follows the same convention as the existing
`pr-description.md` and `release-notes.md` skills in this repo. No harness behavior
in this codebase keys off the field today; it documents intent for the Claude Code
skill registry. The skill is invoked via the slash command (`/translate`) registered
in `.claude/commands/translate.md`, or by name when an agent infers relevance.
-->
# Translate (Plane i18n)
Single source of truth for turning English UI strings into every target locale under `packages/i18n/src/locales/`. Follow this skill exactly — every mistake here ships to every user in that language.
The locale list grows over time. This skill is intentionally generic: rules apply to any locale present now or added later. When you add a locale not explicitly named below, see **Adding a locale not documented here** at the bottom.
Sources distilled from: Microsoft Localization Style Guides, Mozilla L10n, Unicode CLDR plural rules, W3C i18n, Google developer style guide, Apple HIG, GitHub Primer, MQM error typology, Lokalise/Crowdin/Phrase best-practice docs, and Microsoft's AI/LLM-for-translation guidance.
**Source of truth**: `packages/i18n/src/locales/en/<namespace>.json`. Every other locale mirrors English key-for-key. The `sync-check.ts` script catches missing / stale / collision keys; it does **not** catch value-quality mistakes. That is what this skill is for.
## When to Use
- Adding a new key to any `packages/i18n/src/locales/en/*.json`
- Renaming or rewording an English value (every target is now stale)
- Adding a new language (copy from `en/`, then translate each file)
- Syncing after `pnpm --filter @plane/i18n run sync:check` reports drift
- Reviewing any PR that touches `packages/i18n/src/locales/`
Skip only for trivial English-only typo fixes that don't change meaning or length meaningfully.
## The Two Iron Rules
1. **Trademarks, brand marks, plan tier names, third-party product names, acronyms, and code tokens are never translated.** Plane's brand marks (Plane, Plane AI, Power K, PQL, Active Cycles, Sticky/Stickies, Intake), plan tiers (Pro, Business, Enterprise), third-party products (GitHub, Slack, Notion, etc.), and acronyms (API, OAuth, etc.) stay Latin in every locale.
2. **CLDR plural categories are mandatory.** Every target locale must include **every** plural keyword the language requires. Missing a form renders the wrong word at runtime and passes `sync-check` silently.
Common feature nouns — Cycle, Module, Epic, Page — **are translated** into the target language using the canonical glossary further down. They are not brand marks; they are everyday words that belong in the user's language.
The rest of this skill explains how to execute on those rules.
## Do-Not-Translate (DNT) Glossary
For each term: the source, the required rendering per script group, and **forbidden renderings** that have appeared historically and must be reverted when seen.
### Plane brand & features
| Source term | Latin locales (fr, es, it, de, pt-BR, pl, cs, sk, ro, tr-TR, vi-VN, id) | ja (katakana-default) | ko (Hangul-default) | zh-CN / zh-TW | ru | ua | **Forbidden** (never produce) |
| ---------------------------------- | ----------------------------------------------------------------------- | --------------------- | ------------------- | --------------------------------------------- | ----------------- | ----------------- | --------------------------------------------------------------------------------- |
| **Plane** | Plane | Plane | Plane | Plane | Plane | Plane | 飛行機, 飞机, 비행기, Самолёт, Літак, Avion, Avião, Aereo, Flugzeug |
| **Plane AI** (formerly PI Chat) | Plane AI | Plane AI | Plane AI | Plane AI | Plane AI | Plane AI | Чат ИИ, AI 聊天, AIチャット, AI 채팅, Chat IA, AI Çet, PI Chat (legacy) |
| **Power K** | Power K | Power K | Power K | Power K | Power K | Power K | Command K, Command Palette, コマンドパレット, 命令面板, Палитра команд |
| **PQL** | PQL | PQL | PQL | PQL | PQL | PQL | any expansion of the acronym into the target language |
| **Intake** (feature name) | Intake | Intake | Intake | Intake | Intake | Intake | Inbox, 受信箱, 收件箱, Входящие, Triage, Boîte de réception |
| **Active Cycles** (workspace view) | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Active Cycles | Translate as a unit; never split into generic "active" + localized "cycles" |
| **Sticky** / **Stickies** | Sticky / Stickies | Sticky / Stickies | Sticky / Stickies | Sticky / Stickies (Latin inside Chinese text) | Sticky / Stickies | Sticky / Stickies | 便签, 便利貼, メモ, 付箋, スティッキー, 메모, 스티키, заметка, Стикер, Note, Nota |
| **Pro** (plan tier) | Pro | Pro | Pro | Pro | Pro | Pro | Профессиональный, プロフェッショナル, 专业版, 專業版, Profesional |
| **Business** (plan tier) | Business | Business | Business | Business | Business | Business | Бизнес, ビジネス, 商业版, 商務版, Negocios, Negócios |
| **Enterprise** (plan tier) | Enterprise | Enterprise | Enterprise | Enterprise | Enterprise | Enterprise | Корпоративный, エンタープライズ, 企业版, 企業版, Empresarial |
### Plane feature noun translation glossary (translate, do not preserve)
These are common nouns. **Translate them into the target language** using the canonical form below. This follows Microsoft, Apple, and Mozilla style guides for product-UI translation: feature common nouns belong in the user's language; only trademarks, brand marks, and acronyms stay Latin. Leaving these in Latin in non-Latin locales reads as half-translated and is a measurable quality defect.
Use the table verbatim — never coin new variants; never leave the Latin form in the locale value.
| Source | zh-CN | zh-TW | ja | ko | ru | ua | de | fr | es | it | pt-BR | pl | cs | sk | ro | tr-TR | vi-VN | id |
| ----------- | ----- | ----- | ---------- | ------ | -------- | -------- | ------ | ------- | ------- | ------ | ------- | ------ | ------- | ------- | ------- | -------- | ------ | ------- |
| **Cycle** | 周期 | 週期 | サイクル | 사이클 | Цикл | Цикл | Zyklus | Cycle | Ciclo | Ciclo | Ciclo | Cykl | Cyklus | Cyklus | Ciclu | Döngü | Chu kỳ | Siklus |
| **Cycles** | 周期 | 週期 | サイクル | 사이클 | Циклы | Цикли | Zyklen | Cycles | Ciclos | Cicli | Ciclos | Cykle | Cykly | Cykly | Cicluri | Döngüler | Chu kỳ | Siklus |
| **Module** | 模块 | 模組 | モジュール | 모듈 | Модуль | Модуль | Modul | Module | Módulo | Modulo | Módulo | Moduł | Modul | Modul | Modul | Modül | Mô-đun | Modul |
| **Modules** | 模块 | 模組 | モジュール | 모듈 | Модули | Модулі | Module | Modules | Módulos | Moduli | Módulos | Moduły | Moduly | Moduly | Module | Modüller | Mô-đun | Modul |
| **Epic** | 史诗 | 史詩 | エピック | 에픽 | Эпик | Епік | Epic | Epic | Epic | Epic | Epic | Epik | Epik | Epik | Epic | Epik | Epic | Epik |
| **Epics** | 史诗 | 史詩 | エピック | 에픽 | Эпики | Епіки | Epics | Epics | Epics | Epics | Epics | Epiki | Epiky | Epiky | Epice | Epikler | Epic | Epik |
| **Page** | 页面 | 頁面 | ページ | 페이지 | Страница | Сторінка | Seite | Page | Página | Pagina | Página | Strona | Stránka | Stránka | Pagină | Sayfa | Trang | Halaman |
| **Pages** | 页面 | 頁面 | ページ | 페이지 | Страницы | Сторінки | Seiten | Pages | Páginas | Pagine | Páginas | Strony | Stránky | Stránky | Pagini | Sayfalar | Trang | Halaman |
Notes:
- Single-form locales (zh-CN, zh-TW, ja, ko, vi-VN, id) use the same form for singular and plural — the column repeats by design.
- Slavic locales (ru, ua, pl, cs, sk) show **nominative singular** and **nominative plural**. Inside ICU `{count, plural, ...}` blocks, use the case-correct form per CLDR keyword (see the Slavic case-form table further down).
- Some Latin locales (fr, vi-VN, id) have forms identical to English (`Cycle`, `Module`, `Page`) — that's the natural cognate, not a Latin-preservation rule.
- **Epic / Epics**: stays Latin in most Latin locales because there's no clean cognate (Spanish `épico` is for poetry/film; same for it/pt-BR/de/fr). Slavic locales use phonetic transliteration that's standard in their software industry (Эпик, Епік, Epik). CJK locales use the literal/transliterated form (史诗, エピック, 에픽).
- **Generic uses translate normally and don't follow this glossary:** `next page` (paginator), `the page` (browser refresh), `status page`, `web page`, `life cycle`, `release cycle`, `rate-limit cycle`, `cycle of releases` — these are not the Plane Cycle/Page feature; translate them as ordinary words in the surrounding prose.
- When editing an existing file, migrate occurrences you are already touching. Do not bulk-rewrite unrelated strings in the same PR — land a separate sweep PR with the `chore(i18n):` prefix.
#### Slavic case forms inside ICU plural blocks
When a Slavic plural block counts a Plane feature noun, use these forms:
| Locale | Term | one (nom.sg.) | few | many | other |
| ------ | -------- | ------------- | -------- | -------- | -------- |
| **ru** | Цикл | Цикл | Цикла | Циклов | Цикла |
| **ru** | Модуль | Модуль | Модуля | Модулей | Модуля |
| **ru** | Эпик | Эпик | Эпика | Эпиков | Эпика |
| **ru** | Страница | Страница | Страницы | Страниц | Страницы |
| **ua** | Цикл | Цикл | Цикла | Циклів | Цикла |
| **ua** | Модуль | Модуль | Модуля | Модулів | Модуля |
| **ua** | Епік | Епік | Епіка | Епіків | Епіка |
| **ua** | Сторінка | Сторінка | Сторінки | Сторінок | Сторінки |
| **pl** | Cykl | Cykl | Cykle | Cykli | Cyklu |
| **pl** | Moduł | Moduł | Moduły | Modułów | Modułu |
| **pl** | Epik | Epik | Epiki | Epików | Epika |
| **pl** | Strona | Strona | Strony | Stron | Strony |
| **cs** | Cyklus | Cyklus | Cykly | Cyklu | Cyklů |
| **cs** | Modul | Modul | Moduly | Modulu | Modulů |
| **cs** | Epik | Epik | Epiky | Epiku | Epiků |
| **cs** | Stránka | Stránka | Stránky | Stránky | Stránek |
| **sk** | Cyklus | Cyklus | Cykly | Cyklu | Cyklov |
| **sk** | Modul | Modul | Moduly | Modulu | Modulov |
| **sk** | Epik | Epik | Epiky | Epiku | Epikov |
| **sk** | Stránka | Stránka | Stránky | Stránky | Stránok |
Per CLDR for ru/ua: `one` fires for 1, 21, 31… (nom.sg.); `few` for 24, 2224… (gen.sg.); `many` for 0, 520, 2530… (gen.pl.); `other` for non-integer counts (gen.sg.). For pl: `one` (1), `few` (24 not 1214, nom.pl.), `many` (0, 520, gen.pl.), `other` (decimals, gen.sg.). cs/sk: `one` (1), `few` (24, nom.pl.), `many` (decimals, gen.sg.), `other` (0, 5+, gen.pl.).
### Third-party products & standards (always Latin, every locale)
GitHub, GitLab, Bitbucket, Slack, Discord, Zoom, Microsoft Teams, Jira, Linear, Asana, Notion, Confluence, Trello, Figma, Google, Google Drive, Google Calendar, Google Docs, Google Sheets, Gmail, YouTube, Dropbox, Zapier, Tiptap, ProseMirror, Yjs, Hocuspocus, Socket.IO, React, TypeScript, JavaScript, Python, Django, PostgreSQL, Redis, RabbitMQ.
Also preserve: OAuth, OIDC, SAML, SSO, LDAP, API, REST, GraphQL, URL, URI, ID, UUID, JSON, YAML, CSV, TSV, XML, HTML, PDF, PNG, JPG, SVG, CSS, HTTP, HTTPS, TLS, SSL, IP, CIDR, DNS, ARIA, WCAG.
### Inside-string tokens (mechanical — preserve exactly)
- **ICU variables**: `{count}`, `{name}`, `{workspace}`, `{userName}` — never translate, never rename, never move inside `{}`.
- **ICU pluralization / select keywords**: `plural`, `select`, `one`, `few`, `many`, `other`, `zero`, `two`, `=0`, `=1`, `#` — never translate.
- **HTML & JSX tags**: `<b>…</b>`, `<a href="…">…</a>`, `<br/>`, `<code>…</code>` — preserve attributes and nesting; translate only the visible text between tags.
- **i18next `Trans` numbered fragments**: `<0>…</0>`, `<1>…</1>` — preserve the numbers exactly; only translate the wrapped text. Never renumber.
- **Markdown**: `**bold**`, `*italic*`, `` `code` ``, `[text](url)` — keep the syntax; translate only human-readable prose.
- **Escape sequences**: `\n` (newline), `\t`, `\"`, `\\` — preserve at the same positions.
- **Keyboard keys & modifiers**: `Ctrl`, `Cmd`, `Shift`, `Alt`, `Option`, `Enter`, `Return`, `Esc`, `Tab`, `Space`, `Backspace`, arrow glyphs (`↑↓←→`), and OS glyphs (`⌘⌥⌃⇧`) — preserve exactly as shown on the physical key.
- **File extensions & formats**: `.json`, `.csv`, `MM/DD/YYYY` format strings — preserve.
## Per-Script Rules
Apply by target-language script, not by locale list. Adding a new Latin-script locale? It follows the Latin-script rules below. Adding a new Cyrillic locale? It follows the Cyrillic section. Scripts not yet shown here (Arabic, Hebrew, Thai, Greek, Hindi/Devanagari, etc.) — see **Adding a locale not documented here**.
### Latin-script locales
Currently includes fr, es, it, de, pt-BR, pl, cs, sk, ro, tr-TR, vi-VN, id — and any future locale that uses the Latin alphabet (hu, nl, sv, da, nb, fi, hr, bg-using-Latin variants, etc.).
**Translate Plane feature nouns** (Cycle, Cycles, Module, Modules, Epic, Epics, Page, Pages) using the per-locale form from the glossary above. **Keep Latin** for Plane brand marks (Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake), plan tier names (Pro, Business, Enterprise), and third-party brands (GitHub, Slack, etc.).
```
✅ "Créer un Cycle" (fr — natural cognate from glossary, identical to English)
✅ "Crear un nuevo Ciclo" (es — natural Spanish cognate from glossary)
✅ "Archiviare questo Modulo" (it — natural Italian cognate from glossary)
✅ "Nueva Epic" (es — Epic has no clean cognate; stays Latin per glossary)
✅ "Archivieren Sie diesen Zyklus" (de — natural German form from glossary)
✅ "Buat Siklus baru" (id — natural Indonesian form from glossary)
✅ "Buat Sticky baru" (id — Sticky is a Plane brand mark, stays Latin)
✅ "Wechseln Sie zum Pro-Plan" (de — Pro is a plan tier name, stays Latin)
❌ "Créer un Cycle" WRONG when the locale should be `Zyklus` (de). Always use the glossary form.
❌ "Archivieren Sie diesen Cycle" (de — feature noun was left in Latin; use Zyklus per glossary)
❌ "Créer un Cercle" (fr — invented translation; use the glossary form)
❌ "Nueva Saga" (es — translated "Epic" with the wrong cognate)
❌ "Buat Catatan Tempel" (id — translated "Sticky" which is a brand mark; keep Latin)
```
Generic uses translate normally and do not follow the glossary: a paginator's `next page` is `nächste Seite` / `página siguiente` (generic noun, not the Plane Pages feature). The glossary applies only when EN refers to the Plane product feature.
### Japanese (ja) — natural Japanese rendering
**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Japanese form per the glossary above (サイクル, モジュール, エピック, ページ — katakana for foreign-origin nouns; native Japanese where one applies, like 付箋 for an Apple/Microsoft-style "sticky note"). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise.
For new katakana coinages and existing translations, the long-vowel mark `ー` is added for words ending in `-er`, `-or`, `-ar`, `-y` in English. This is the Microsoft Japanese style-guide convention and the current industry default:
- user → **ユーザー** (not ユーザ)
- server → **サーバー** (not サーバ)
- editor → **エディター** (not エディタ)
- property → **プロパティー** (_kept as_ プロパティ where the existing codebase already does — match the surrounding file's convention)
Tone: polite form です・ます. Never plain form だ・である. Avoid over-formal 尊敬語・謙譲語 for SaaS product copy — it reads stilted.
Quotation marks: 「」 for primary quotes, 『』 for nested or for titles of works. Full-width punctuation: 。、()・!?.
### Korean (ko) — Hangul rendering
**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Korean form per the glossary above (사이클, 모듈, 에픽, 페이지 — Hangul transliteration where the term is product-coined; native Korean word where one applies). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise.
For new terms not on the glossary: prefer the native Korean word where one exists (`설정` for Settings); use Hangul phonetic transliteration for product-coined nouns with no native equivalent. Do not coerce a phonetic loanword when a natural Korean word exists — `버킷` for "Bucket" reads as a foreign trademark, `장바구니` reads as a Korean noun.
**Korean particle agreement**: when introducing a consonant-ending noun (사이클, 에픽, 모듈), follow with the consonant-form particle (을/은/이/과), not the vowel-form (를/는/가/와). `사이클을 추가` not `사이클를 추가`.
Register:
- **System and error messages** → 합니다체 (high-formal: 합니다, 됩니다, 입니다). Existing files in this register today: `ko/auth.json`, `ko/error/*.json`.
- **Empty states, onboarding microcopy, tooltips** → 해요체 acceptable (softer: 해요, 돼요, 이에요) if the existing file uses it. Existing files in this register today: `ko/empty-state.json`, `ko/tour.json`. Always match the surrounding file rather than introducing a new register mid-namespace.
- Never casual 반말.
Punctuation: Western punctuation (`. , ? !`); straight quotes `"…"` and `'…'`.
### Simplified Chinese (zh-CN) and Traditional Chinese (zh-TW) — translate feature nouns
**Plane feature nouns** (Cycle, Module, Epic, Page) translate to natural Chinese per the glossary above (zh-CN: 周期, 模块, 史诗, 页面 — zh-TW: 週期, 模組, 史詩, 頁面). This is what Microsoft's zh-CN style guide and every mainstream zh-localized SaaS product (Notion, Slack, Atlassian) does for common feature nouns.
**Brand marks stay Latin**: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise — these are trademark-style terms.
**Insert a half-width space on each side of embedded Latin tokens** — this is Microsoft's zh-CN guideline and required for legibility. **Exception**: no space between a Latin token and adjacent full-width punctuation (`。,;:?!`); the punctuation already supplies visual breathing room.
```
✅ "使用 GitHub 登录" (Latin brand → half-width spaces around)
✅ "创建周期" (zh-CN feature noun translated; no Latin = no spaces)
✅ "归档此史诗" (Plane Epic → 史诗 per glossary)
✅ "添加 Sticky" (Sticky is a brand mark → Latin + half-width space)
✅ "升级到 Pro" (Pro is a plan tier → Latin)
✅ "登录 GitHub。" (no space between Latin token and full-width period)
❌ "使用GitHub登录" (Latin brand without surrounding half-width space)
❌ "创建 Cycle" (feature noun left in Latin — should be 周期)
❌ "创建Cycle" (feature noun in Latin AND missing space)
❌ "登录 GitHub 。" (stray space before full-width period)
❌ "创建赛克" (invented transliteration of Cycle — use the glossary's 周期)
```
Punctuation is **full-width**: 。,?!;:. Quotes:
- zh-CN: `"…"` (primary) and `'…'` (nested)
- zh-TW: `「…」` (primary) and `『…』` (nested)
- Work titles (book/movie/app/article names): `《…》`
Variant discipline: zh-CN ≠ zh-TW. They differ in script (简体 vs 繁體) **and** vocabulary (视频 vs 影片; 软件 vs 軟體; 网络 vs 網路). Never mass-copy between the two directories.
Register: 您 (formal polite) in Plane UI — the product is B2B/SaaS. Reserve 你 for consumer/youth contexts (not applicable here).
### Cyrillic locales
Currently includes ru, ua — and any future Cyrillic locale (bg-BG, sr-Cyrl, mk, be, kk-Cyrl, etc.).
**Plane feature nouns** (Cycle, Module, Epic, Page) use the natural Cyrillic form per the glossary above (Цикл/Циклы, Модуль/Модули, Эпик/Эпики, Страница/Страницы in ru — Цикл/Цикли, Модуль/Модулі, Епік/Епіки, Сторінка/Сторінки in ua). **Brand marks** stay in Latin: Plane, Plane AI, Power K, PQL, Active Cycles, Sticky, Stickies, Intake, GitHub, Slack, Pro/Business/Enterprise.
For new terms not on the glossary: prefer the native Cyrillic word where one exists; use phonetic transliteration only when no native word applies (`Бакет` is wrong for "Bucket" — use `Корзина`/`Кошик`).
**Slavic case forms inside `{count, plural, ...}` blocks** are case-correct per CLDR keyword (one=nom.sg., few=gen.sg., many=gen.pl., other=gen.sg.). See the case-form table in the glossary section.
Register:
- **ru**: formal **Вы** (capitalized) when addressing a **single user directly** in respectful/formal contexts (onboarding, settings, confirmation dialogs); lowercase **вы** for plural/general references ("все вы"). Default to capitalized Вы in tooltips/dialogs and lowercase вы in descriptive copy.
- **ua**: modern Ukrainian software convention is lowercase **ви** by default; capitalized **Ви** is reserved for very formal direct address. This is the opposite convention from Russian — do not copy-paste Russian capitalization rules into Ukrainian files.
Punctuation: primary quotes `«…»`, nested `„…"`.
**Plural forms — critical**: both ru and ua require `one / few / many / other`. See CLDR section below.
## CLDR Plural Rules
Every `{count, plural, …}` string must contain **every** keyword the target language's CLDR rule requires. `other` is always required, even in single-form languages.
**Canonical source**: Unicode CLDR Language Plural Rules chart — the definitive, versioned list of required categories for every language. Always verify against the current CLDR chart when adding a locale, since rules occasionally shift across CLDR releases. Libraries like `Intl.PluralRules` can resolve the rule at runtime.
Below are the required keywords for locales currently in the repo. When adding a new locale, look up its categories in CLDR and add the row here.
| Locale | Required keywords | Example mapping |
| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| en, es, de, tr-TR, vi-VN, id, ja, ko, zh-CN, zh-TW | `one, other` (single-form locales may use `other` alone, but **always emit `other`**) | 1 → one; 2+ → other |
| fr | `one, many, other` | 0, 1 → one; 1 000 000 → many; else other |
| it | `one, many, other` | 1 → one; 1 000 000 → many; else other |
| pt-BR | `one, many, other` | 0, 1 → one; 1 000 000 → many; else other |
| ro | `one, few, other` | 1 → one; 0, 219 → few; 20+ → other |
| pl | `one, few, many, other` | 1 → one; 24 (not 1214) → few; 0, 520, many-digit → many; decimals → other |
| cs | `one, few, many, other` | 1 → one; 24 → few; decimals → many; 0, 5+ → other |
| sk | `one, few, many, other` | same pattern as cs |
| ru | `one, few, many, other` | 1, 21, 31… → one; 24, 2224… → few; 0, 520, 2530… → many; decimals → other |
| ua | `one, few, many, other` | same pattern as ru |
| ar (when added) | `zero, one, two, few, many, other` | Six categories — the maximum any language uses |
> **Note on the consolidated row.** CLDR itself only requires `other` for `tr-TR`, `vi-VN`, `id`, `ja`, `ko`, `zh-CN`, `zh-TW` (single-form locales). Emitting both `one` and `other` with identical content is a **project convention** — it keeps tooling and linters consistent across the codebase. It is not a CLDR requirement, and `Intl.PluralRules` will return only `"other"` for these locales at runtime.
For any locale not listed: look up the CLDR categories before writing a single plural string. Arabic has six; Welsh has six; most Slavic languages have four; most Romance languages have two or three; CJK / Turkic / Thai have one (still emit `other`).
Rules in practice:
1. **Never drop a required form**, even when the word is identical across forms — emit each one explicitly.
2. **Never add a form the target doesn't have.** German does **not** use `few`. Any `few` clause in `de/*.json` is a bug and must be removed.
3. In single-form locales (ja/ko/zh/tr/vi/id), emit `one` + `other` with identical content so tooling and linters stay consistent.
4. The `other` case is always the fallback — it must render a grammatically complete sentence on its own.
Examples:
```json
// ✅ Correct — Russian (four forms; `other` fires for non-integer counts and takes genitive singular)
"members": "{count, plural, one {# участник} few {# участника} many {# участников} other {# участника}}"
// ✅ Correct — Polish (four forms)
"items": "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}"
// ✅ Correct — Romanian (three forms)
"days": "{count, plural, one {# zi} few {# zile} other {# de zile}}"
// ✅ Correct — French (three forms; `many` covers 1 000 000, 2 000 000, …)
"members": "{count, plural, one {# membre} many {# membres} other {# membres}}"
// ✅ Correct — Japanese (single form, both keywords emitted)
"label": "{count, plural, one {サイクル} other {サイクル}}"
// ❌ Wrong — German with spurious `few`
"label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}"
// ❌ Wrong — Russian missing `few` and `many`
"label": "{count, plural, one {Цикл} other {Циклы}}"
```
## Placeholders, HTML, Markdown (preservation checklist)
For every translated string, verify before saving:
- [ ] Identical set of `{variables}` in source and target (character-for-character — `{userName}` is not `{username}`)
- [ ] Identical HTML/JSX tags with identical attributes and nesting
- [ ] Identical i18next `Trans` numbered tags (`<0>…</0>`, `<1>…</1>` — never renumber)
- [ ] Identical Markdown syntax (`**bold**`, `` `code` ``, `[link text](url)`)
- [ ] Identical `\n` positions (layout relies on these)
- [ ] No additions (no extra explanatory words), no omissions (no collapsed clauses)
Variables **may be repositioned** for grammatical fit:
```
en: "Welcome, {name}! You have {count} new work items."
fr: "Bienvenue, {name} ! Vous avez {count} nouveaux work items."
ja: "{name}さん、ようこそ。{count}件の新しい作業項目があります。"
```
## Per-Locale Punctuation & Spacing
Apply in every translated string. These are MQM "Locale Conventions" violations when missed — a measurable quality defect.
Table below covers locales currently in the repo; add a row when you add a locale.
| Locale | Key rules |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **fr** | Narrow NBSP (U+202F, fallback U+00A0) **before** `:`, `;`, `?`, `!`, `%`, `»` and **after** `«`. Primary quotes `« »` (with NBSPs), nested `"…"`. |
| **de** | Primary quotes `„…"` (low-high); **no** NBSP before punctuation. Avoid imperatives in system strings; prefer infinitive constructions ("Werkelement erstellen", not "Erstelle ein Werkelement"). |
| **es** | Inverted `¿…?` and `¡…!` at the start of questions/exclamations. Primary quotes `«…»` or `"…"`. |
| **it** | Primary quotes `«…»` or `"…"`. |
| **pt-BR** | Straight quotes `"…"`. |
| **pl** | Primary quotes `„…"`. |
| **cs / sk** | Primary quotes `„…"`. |
| **ro** | Primary quotes `„…"`. |
| **tr-TR / vi-VN / id** | Straight quotes `"…"`. Vietnamese diacritics are mandatory — never strip (no `bạn``ban`). |
| **ru / ua** | Primary `«…»`, nested `„…"`. |
| **ja** | Full-width `。、()!?・`. Primary quotes 「」, nested 『』. Ellipsis `…` (U+2026), typically doubled as `……`. |
| **ko** | Western `. , ? !`; straight quotes `"…"`. |
| **zh-CN** | Full-width `。,?!;:`. Primary `"…"`, nested `'…'`. Work titles `《…》`. Half-width space around embedded Latin tokens. |
| **zh-TW** | Full-width `。,?!;:`. Primary `「…」`, nested `『…』`. Work titles `《…》`. Half-width space around embedded Latin tokens. |
## Tone & Register (SaaS defaults)
Formality defaults for locales currently in the repo. Add a row for each new locale, consulting the Microsoft Style Guide for that locale as the default authority on product-UI register.
| Locale | Default "you" | Notes |
| ------- | ---------------------------------------------------------------------------- | ------------------------------------------------ |
| fr | **vous** | Never "tu" in product UI |
| es | **usted** + **ustedes** | Neutral Spanish — no "vosotros", no "tú" |
| it | **Lei** (formal third-person) | B2B/enterprise convention |
| de | **Sie** | Use infinitive constructions for system messages |
| pt-BR | **você** | Semi-formal default; never "tu" |
| pl | **Pan/Pani** + 3rd-person, or impersonal | Never "ty" |
| cs / sk | **Vy** (formal, 3rd-person plural) | Never "ty" |
| ro | **dumneavoastră** (formal) | Or impersonal |
| tr-TR | **siz** + `-iniz` endings | Never "sen" |
| vi-VN | **bạn** (safe neutral) | Never "mày/tao"; consistency > variety |
| id | **Anda** (always capitalized) | Never "kamu" in product UI |
| ja | **です・ます体** | Never plain form; avoid 尊敬語・謙譲語 |
| ko | **합니다체** for system/errors; **해요체** acceptable for onboarding | Match surrounding file |
| zh-CN | **您** | B2B convention; never 你 |
| zh-TW | **您** | B2B convention; never 你 |
| ru | **Вы** (cap.) for singular direct address, **вы** (lower) for plural/general | Context-dependent |
| ua | **ви** (lowercase, modern convention) | Capitalized Ви only for very formal |
General writing rules (apply across all locales):
- Sentence case in buttons, headings, tooltips — not Title Case. Only capitalize proper nouns.
- No exclamation marks except in genuine celebrations (first success, milestone).
- No emoji in UI copy.
- No slang, idioms, humor, cultural references — they don't translate.
- Consistent terminology — if "work item" is used once, never switch to "issue" / "ticket" mid-flow.
- Active voice, present tense.
- Write out abbreviations on first use if not on the DNT list.
## Text Expansion Budget
Design strings knowing translations will grow. Typical expansion vs. English for locales currently in the repo; lookup values from Andiamo / Eriksen / W3C tables when adding a locale.
| Locale | Expansion | Implication |
| ------------ | ------------------------------------------------- | ----------------------------------------------- |
| de | +1035%, single words up to +180% | Reserve generous space on buttons, tabs, labels |
| fr | +1525% | |
| es | +1530% | |
| it | +1025% | |
| pt-BR | +1530% | |
| pl | +2030% | |
| cs, sk | +1020% | |
| ro | +1525% | |
| ru, ua | +15% typical, spikes to +30% | |
| tr-TR | +1030% (agglutination) | |
| vi-VN | +3040% | Diacritic-heavy, many small words |
| id | +1020% | |
| ja | 10 to 55% character count (similar pixel width) | |
| ko | 10 to 15% | |
| zh-CN, zh-TW | 40% character count (≈2× char width) | |
Rule of thumb: any UI surface must absorb **+35%** without truncation. If a string is length-capped, add a comment or context in the source.
## Numbers, Dates, Currency, Units
**Never hard-code formats.** Use ICU skeletons so the runtime localizes automatically:
```json
"updated_on": "Updated {date, date, medium}",
"percent_done": "{ratio, number, percent} complete",
"item_count": "{count, number} items"
```
Never write `"{date}MM/DD/YYYY"` in the source — that guarantees a locale bug.
Decimal/thousands separators (runtime-handled):
- en: `1,234.56` — de/it/es/pt-BR/ru/ua/pl/cs/sk/tr-TR: `1.234,56` or `1 234,56` — fr: `1 234,56` (thin space) — ja/ko/zh: `1,234.56`.
Date formats (runtime-handled):
- en-US: `MM/DD/YYYY` — most of Europe: `DD.MM.YYYY` — fr: `DD/MM/YYYY` — ja: `YYYY/MM/DD` or `YYYY年MM月DD日` — zh: `YYYY年MM月DD日` — ko: `YYYY년 MM월 DD일`.
Currency: prefer ISO codes (`USD`, `EUR`, `INR`) in dense UI; localize symbol position via ICU. Units: keep the system (metric/imperial) a product decision, localized consistently.
## AI Translation Workflow
The repo has no machine-readable translation-status field today (no sidecar, no `__meta`, no per-key flag) — so "preserve human-reviewed strings" must be enforced via git history, not status metadata. The disciplines below are what actually fires:
1. **Inject the DNT glossary into every AI translation prompt** — both approved targets and forbidden renderings. LLMs do not natively honor glossaries; they obey them only when prompted.
2. **Prompt the variant explicitly**: `target=zh-CN` vs `target=zh-TW`, `target=pt-BR` vs `target=pt-PT`. Auto-detect calls blend them and produce mixed vocabulary.
3. **Pass 25 sentences of real context** per string (surrounding UI, screen, flow). Keep DNT instructions out of context — route them through the glossary/system prompt channel.
4. **Hallucination check**: AI output may add or drop content. Diff placeholder/tag inventory before and after; any mismatch is a reject.
5. **On re-translation, preserve human-touched lines.** Before overwriting any value in an existing locale file, run `git log -- <file>` (or `git blame -L <line>,<line>`) on the key. If the most recent non-bot, non-mass-rewrite commit was authored by a human, treat that line as human-reviewed and keep it. Only overwrite lines whose last edit was a machine sweep or a copy-from-en migration.
6. **Variants by language-resource tier**: expect more review iterations for id, vi-VN, ua, ro (lower-resource LLMs) than for de, fr, es, ja (higher-resource).
### Per-string review rubric (MQM-aligned)
When reviewing a translation — yours or an AI's — score against these categories and reject if any Major issue is present:
1. **Terminology** — DNT violation, inconsistent with the glossary above, or inconsistent with past strings in the same namespace
2. **Accuracy** — Mistranslation, addition (invented content), omission (dropped content), wrong variant (zh-CN vs zh-TW), hallucination
3. **Linguistic** — Grammar, punctuation (per-locale table), spelling, encoding (mojibake, half-width where full-width required)
4. **Style / Register** — Informal pronoun used where formal is required; inconsistent tone; idiom/cultural reference
5. **Locale conventions** — Date / number / currency format hard-coded; decimal separator wrong; keyboard key translated
6. **Markup** — Placeholder changed, HTML tag dropped/modified, Markdown broken, `Trans` numbered fragment renumbered
7. **Plural** — Missing required CLDR form; spurious form the language doesn't have; `#` or `=0` dropped
## Workflow
### Add a new key
1. Add to `packages/i18n/src/locales/en/<namespace>.json` first. (If the namespace file does not yet exist, see **Add a new namespace** below — that case has extra steps.)
2. Use in the component via `t("my.new_key")`.
3. Translate into every target locale present in `src/locales/` — one file at a time. **Do not** copy the English value into the non-English files as a shortcut — `sync-check` treats presence as synced and will silently ship English copy to every locale.
4. Run `pnpm --filter @plane/i18n run generate:types` (or let the build do it).
5. Run `pnpm --filter @plane/i18n run sync:check` — expect `0 missing, 0 stale, 0 collisions`.
6. Spot-check one non-Latin locale manually for punctuation/plural correctness.
### Add a new namespace
A "namespace" is a top-level JSON file (e.g. `common.json`, `auth.json`, `epic.json`). The set of valid namespace names is a hard-coded const array — adding a JSON file alone is not enough; the runtime will not load it.
1. Add the new namespace name to the `NAMESPACES` array in `packages/i18n/src/constants/namespaces.ts`. Keep alphabetical order with the existing entries.
2. Create `<namespace>.json` in **every** locale directory under `packages/i18n/src/locales/` — start with `en/<namespace>.json` (the source of truth), then create the file in all 18 target locales. An empty `{}` is fine for the targets at this step; `sync-check` will report missing keys as you add them in step 4.
3. Add at least one key in `en/<namespace>.json` so the namespace has content.
4. Translate every key from `en/<namespace>.json` into each target locale — apply every rule in this skill.
5. Run `pnpm --filter @plane/i18n run generate:types` to regenerate the `TTranslationKeys` union so component-level `t()` calls type-check.
6. Run `pnpm --filter @plane/i18n run sync:check` — expect `0 missing, 0 stale, 0 collisions`.
7. Use the new namespace from a component via the namespace-prefixed key (e.g. `t("<namespace>.my_key")`) and spot-check the rendered UI in at least one non-Latin locale.
### Update an English value
The meaning changed — every target is now stale even if the key still exists. `sync-check` will **not** flag this.
1. Edit `en/<namespace>.json`.
2. Update the same key in every target locale in the same PR.
3. If a locale has no native speaker available in the PR, mark the string status as `machine_translated` (in PR description or commit message) and open a follow-up for native review.
### Add a new language
1. `cp -r packages/i18n/src/locales/en packages/i18n/src/locales/<xx>`
2. Translate every file, applying every rule in this skill.
3. Register in `packages/i18n/src/constants/language.ts` (`SUPPORTED_LANGUAGES`) and `packages/i18n/src/types/language.ts` (`TLanguage`).
4. Run `sync:check` — must show 100% coverage before merging.
5. Before merging, **pseudolocalize**: temporarily replace the new locale's values with bracketed expanded forms (`"Plane" → "[Plàññéé——]"`) in a local build and click through the UI. Catches truncation, unextracted strings, and layout bugs before real users see them.
6. If the new locale isn't yet covered by this skill (script, plural rules, punctuation, register), follow **Adding a locale not documented here** below and update this file in the same PR.
### Commands
```bash
# Regenerate the TTranslationKeys union (auto on build)
pnpm --filter @plane/i18n run generate:types
# Report drift
pnpm --filter @plane/i18n run sync:check
# Same, exit 1 on drift (for CI)
pnpm --filter @plane/i18n run check:sync
```
## Quick Reference
| Question | Answer |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Translate "Plane" / "Plane AI" / "Power K" / "PQL"? | Never. Latin, every locale. |
| Translate "Sticky" / "Stickies" / "Intake"? | Never. Plane brand marks. Latin, every locale. |
| Translate "Active Cycles"? | Never as a unit (it's a feature page name). Lowercase generic "active cycles" in prose translates normally per the glossary. |
| Translate "Pro" / "Business" / "Enterprise"? | Never. Plan tier names. Latin, every locale. |
| Translate "Cycle" / "Module" / "Epic" / "Page"? | **Yes** — use the per-locale form from the translation glossary (zh-CN 周期/模块/史诗/页面, ja サイクル/モジュール/エピック/ページ, de Zyklus/Modul/Epic/Seite, etc.). |
| Translate "GitHub" / "Slack" / third-party brands? | Never. Latin, every locale. |
| Translate a variable name `{name}`? | Never. Preserve exactly. |
| Translate `<0>…</0>`? | Translate only the inside text. Never renumber. |
| Russian plural forms? | `one / few / many / other` — four forms mandatory; case-correct per CLDR. |
| German plural forms? | `one / other` — never `few`. |
| French plural forms? | `one / many / other``many` covers 1M+. |
| CJK plural forms? | `one / other` (single-form) — still emit both. |
| Informal "you" in de/fr/ru/ja? | Never in product UI. |
| Chinese + embedded "GitHub"? | `使用 GitHub 登录` — half-width space around Latin **brand** tokens. Feature nouns translate (`创建周期`, no space because no Latin). |
| Japanese "user"? | ユーザー with long-vowel ー, not ユーザ. |
| Hard-code date formats? | Never. Use ICU `{d, date, medium}`. |
| Copy English into non-English locale? | Never. Worse than leaving the key missing. |
## Common Mistakes (revert on sight)
- **Translating Plane brand marks** — `Plane → 飞机`, `Plane AI → AI 聊天`, `Power K → 命令面板`, `Sticky → 便签` (Sticky is a brand, even though "sticky note" generally translates), `Intake → 收件箱`. Brand marks stay Latin.
- **Leaving feature nouns in Latin in non-Latin locales** — `创建 Cycle` (zh-CN), `Создать Cycle` (ru), `エピックを作成` is fine but `Epicを作成` is not. Use the per-locale form from the glossary.
- **Coining new feature-noun translations** — `Cycle → Cercle` (fr — invented; the natural cognate is the same `Cycle`), `Cycle → 循环` (zh-CN — non-glossary; use `周期`), `Epic → Saga` (es — non-glossary; use `Epic`). The glossary is the source of truth.
- **Missing `few` / `many` in Slavic languages** — Russian/Polish/Czech/Slovak/Ukrainian strings with only `one / other` are grammatically wrong for counts 24 and 5+. Fix in the same PR.
- **Wrong Slavic case form inside ICU plurals** — for ru/ua, `few` is genitive singular (`# Цикла`), not nominative plural (`# Циклы`). See the case-form table.
- **Inventing `few` in German, `many` in Spanish, etc.** — languages not in CLDR for that form. Remove the spurious keyword.
- **Translating `{count}` or renaming `{name}`** — ICU variables are lookup keys; rename breaks runtime substitution.
- **Dropping `<0>`/`<1>` numbering in `Trans`** — react-i18next matches by number; renumbering breaks the component.
- **Copying English as a translation** — `sync-check` passes, users see English. The fastest way to ship bad i18n.
- **No half-width space around Latin tokens in Chinese** — `使用GitHub登录` is a readability bug. The space rule applies around any remaining Latin token (brand mark), not around translated feature nouns.
- **Missing `ー` in Japanese katakana (`ユーザ` instead of `ユーザー`)** — inconsistent with the Microsoft style guide and the rest of the product.
- **Using informal pronouns** — `du/tu/ты/tú/you (informal)` breaks Plane's formal register in languages that distinguish.
- **Translating keyboard keys** — `Ctrl` stays `Ctrl`, never `Strg` or `コントロール`, because the physical key says `Ctrl`.
- **Hard-coded date/number strings** — `"Due on MM/DD/YYYY"` is a locale bug waiting to ship; use ICU formatters.
- **Translating `PQL`, `SSO`, `API`** — acronyms are DNT. The prose around them may be translated.
- **Translating plan tier names** — `Pro → Профессиональный`, `Business → 商业版`, `Enterprise → エンタープライズ`. Plan tiers stay Latin per industry convention (Notion/Slack/Linear/Asana).
- **Korean particle mismatch after Hangul transliteration** — `사이클를` is wrong (vowel-particle after consonant-ending noun); use `사이클을`. Same for 모듈, 에픽.
- **Vietnamese diacritic stripping** — `bạn` is not `ban`; diacritics are mandatory.
- **Lowercase `anda` in Indonesian** — `Anda` is always capitalized in product UI.
- **Mixing zh-CN and zh-TW vocabulary** — `视频``影片`, `软件``軟體`. Never copy-paste between the two directories.
## Red Flags — Stop and Revert
You see yourself about to do any of the following → stop, delete, restart this string:
- Replacing `Plane`, `Plane AI`, `Power K`, `PQL`, `Active Cycles`, `Sticky`, `Stickies`, `Intake`, `Pro`, `Business`, `Enterprise`, or any third-party brand (`GitHub`, `Slack`, etc.) with a translated form.
- Leaving `Cycle` / `Module` / `Epic` / `Page` in Latin in a non-Latin locale (zh-CN, zh-TW, ja, ko, ru, ua) — these are common nouns and must be translated per the glossary.
- Coining a feature-noun translation that's not in the glossary (`Cycle → 循环`, `Cycle → Cercle`, `Epic → Saga`).
- Using a vowel-form Korean particle (을/은/이/과 vs. 를/는/가/와) that doesn't agree with the noun's final character.
- Pasting the English value into a non-English file.
- Renaming or removing a `{variable}`, `<tag>`, numbered `<0>`, or Markdown marker.
- Dropping `few` or `many` from a Slavic-language plural, or adding `few` to German.
- Using nominative plural for `few` in ru/ua plural blocks (it should be genitive singular per CLDR).
- Editing one locale file and deciding to "do the others later" without updating the PR description.
- Using `du`, `tu`, `ты`, `tú`, plain-form Japanese, or 반말 Korean in any product string.
- Stripping diacritics from Vietnamese, lowercasing `Anda` in Indonesian.
- Hard-coding a date or number format.
## Rationalizations — Use Reality Column Instead
| Excuse | Reality |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| "Keeping `Cycle` Latin in zh-CN keeps it consistent with English docs." | That's a minority position; Microsoft, Apple, Mozilla, Notion, Atlassian, Slack all translate. Monolingual zh users can't read Latin words; the glossary form is the standard. |
| "史诗 is the established Chinese word for Epic, but Latin reads more brand-y." | Brand-y is the wrong goal for a feature noun. The glossary form (`史诗` for zh-CN, `Эпик` for ru) is what users in those locales expect from a SaaS product. |
| "Our Russian users understand `Cycle` Latin." | They understand it; they don't expect to encounter it. The glossary form (`Цикл`) is what every other Russian SaaS product uses for the same concept. |
| "I'll just transliterate the Plane brand name into Cyrillic for accessibility." | Brand marks stay Latin in every locale. `Плейн` is wrong; `Plane` is right. |
| "Plan tier names should be translated since they're plain words." | Industry standard (Notion, Slack, Linear, Asana, GitHub) is to keep `Pro`, `Business`, `Enterprise` Latin for marketing consistency. Don't translate. |
| "This string is internal / rarely seen, good-enough is fine." | Every string is someone's main screen. Rules are cheap to follow; consistency compounds. |
| "`sync-check` passed, I'm done." | `sync-check` only compares presence, not value quality. Green does not mean translated. |
| "I'll fix the missing plural forms later." | Missing `few` in Russian renders the wrong word for counts 24 right now in production. Same PR. |
| "The old file already used Latin Cycle everywhere — stay consistent." | Consistency with a bug is still a bug. Migrate occurrences you touch; open a sweep PR for the rest. |
| "AI said this was the right Japanese word for Cycle." | AI does not know our glossary unless you inject it. Check against the glossary table above; the glossary wins. |
| "The German translation is a bit long but still fits on my screen." | It won't fit on every user's screen. Design for +35% expansion; test in a narrow viewport. |
| "I renamed `{userName}` to `{nomeUtente}` so the Italian reads naturally." | Variables are code. The runtime has no `{nomeUtente}` in scope — it renders as literal text. Revert. |
| "Capitalizing `Anda` / `您` / `Sie` looks over-formal." | It's Plane's register in those languages. Deviating breaks brand voice across the product. |
## Adding a locale not documented here
When you add a language whose script, plural rules, punctuation, or register defaults aren't covered above:
1. **Plural rules** — Look up the target language in the Unicode CLDR Language Plural Rules chart. Note every required keyword (`zero`, `one`, `two`, `few`, `many`, `other`). Add a row to the CLDR table above.
2. **Script & transliteration** — Identify the script family. Latin → preserve DNT verbatim. Non-Latin → decide per-script: transliterate common-noun feature names phonetically (Cyrillic / Devanagari / Greek / Thai), keep brand marks in Latin. For RTL scripts (Arabic, Hebrew, Persian, Urdu) set `dir="rtl"` at the root and ensure Latin tokens remain LTR inside RTL context; preserve `&rlm;`/`&lrm;` markers if present.
3. **Punctuation & spacing** — Consult the Microsoft Style Guide for the locale (canonical authority on SaaS-style product punctuation). Add a row to the Per-Locale Punctuation & Spacing table.
4. **Register** — Default to the formal "you" and whatever honorific/polite register the Microsoft guide prescribes for B2B SaaS. Add a row to the Tone & Register table.
5. **Text expansion** — Look up typical expansion in Andiamo / Eriksen / W3C text-size tables; add a row to the Text Expansion Budget.
6. **DNT glossary** — Add a column (or row entries) for the new locale across the DNT tables. Specify forbidden literal translations (the words an LLM would produce if uninstructed). Example: if adding Arabic, the forbidden rendering for `Plane` includes `طائرة`; for `Epic` includes `ملحمة`; for `Sticky` includes `ملاحظة`.
7. **Commit this file in the same PR** that introduces the locale. The skill must stay ahead of the codebase — empty per-locale rows guarantee inconsistent translations in future PRs.
Canonical references to consult:
- **Unicode CLDR** — plural categories, number/date formats, collation.
- **Microsoft Style Guides** — per-locale register, punctuation, abbreviation handling, trademark rules (90+ locales covered).
- **Mozilla L10n Style Guides** — additional per-locale typography and brand-preservation rules.
- **W3C i18n** — RTL/bidi, placeholder handling, UTF-8, escapes, text expansion.
## References
- Microsoft Localization Style Guides — per-locale definitive source, formal-register defaults
- Unicode CLDR Plural Rules — canonical per-locale plural category list
- Mozilla L10n Style Guides — trademark/brand preservation rules, per-locale typography
- W3C i18n Quick Tips — placeholder handling, text expansion, UTF-8 and escapes
- Google developer style guide "Writing for a global audience" — source-string best practices
- MQM (Multidimensional Quality Metrics) — review rubric used in this skill's `Per-string review rubric` section
- Microsoft AI/LLMs for translation guidance — glossary injection, variant prompting, human-review gates
+51
View File
@@ -0,0 +1,51 @@
name: i18n sync check
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "reopened"
- "ready_for_review"
paths:
- "packages/i18n/**"
- ".github/workflows/i18n-sync-check.yml"
push:
branches:
- "preview"
paths:
- "packages/i18n/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-check:
name: check:sync
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'push' || github.event.pull_request.draft == false
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
- name: Run sync check
run: pnpm dlx tsx packages/i18n/scripts/sync-check.ts --ci
+7
View File
@@ -110,3 +110,10 @@ build/
.react-router/
temp/
scripts/
!packages/i18n/scripts/
# i18n auto-generated types (regenerated on every build)
packages/i18n/src/types/keys.generated.ts
# Local security notes (not for version control)
/security/
+10
View File
@@ -0,0 +1,10 @@
# Trivy ignore file
# Document the rationale for each suppressed finding.
# CVE-2026-30242: SSRF in Plane webhook URL serializer.
# False positive: Trivy matches our backend's distribution name "Plane" +
# version 0.24.0 against the makeplane/plane CVE. The "fixed in 1.2.3" refers
# to the upstream public release version scheme, not this distribution's
# pyproject.toml version - the SSRF mitigation has been in place for the
# applicable webhook validation code path.
CVE-2026-30242
+12
View File
@@ -22,3 +22,15 @@
- **State Management**: MobX stores in `packages/shared-state`, reactive patterns
- **Testing**: All features require unit tests, use existing test framework per package
- **Components**: Build in `@plane/ui` with Storybook for isolated development
## Backend tests (Docker)
The Django/pytest suite for `apps/api` runs in an isolated stack defined by `docker-compose-test.yml` at the repo root.
Prereq (once): `./setup.sh` — generates `apps/api/.env` from `.env.example`.
- Full suite: `docker compose -f docker-compose-test.yml up --build --abort-on-container-exit --exit-code-from api-tests`
- Subset: `docker compose -f docker-compose-test.yml run --rm api-tests pytest -m unit`
- Teardown: `docker compose -f docker-compose-test.yml down -v`
See `apps/api/tests/RUNNING_TESTS.md` for the full walkthrough and troubleshooting; see `apps/api/tests/TESTING_GUIDE.md` for test conventions and fixtures.
+3 -1
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.9.4
RUN pnpm add -g turbo@2.9.14
COPY . .
@@ -77,6 +77,8 @@ RUN pnpm turbo run build --filter=admin
FROM nginx:1.29-alpine AS production
RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
+1 -1
View File
@@ -11,7 +11,7 @@ http {
set_real_ip_from 0.0.0.0/0;
real_ip_recursive on;
real_ip_header X-Forward-For;
real_ip_header X-Forwarded-For;
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
access_log /dev/stdout;
+12 -11
View File
@@ -19,10 +19,10 @@
},
"dependencies": {
"@bprogress/core": "catalog:",
"@fontsource-variable/inter": "5.2.8",
"@fontsource/ibm-plex-mono": "5.2.7",
"@fontsource/material-symbols-rounded": "5.2.30",
"@headlessui/react": "^1.7.19",
"@fontsource-variable/inter": "catalog:",
"@fontsource/ibm-plex-mono": "catalog:",
"@fontsource/material-symbols-rounded": "catalog:",
"@headlessui/react": "catalog:",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/propel": "workspace:*",
@@ -31,20 +31,20 @@
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@react-router/node": "catalog:",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/virtual-core": "^3.13.12",
"@tanstack/react-virtual": "catalog:",
"@tanstack/virtual-core": "catalog:",
"axios": "catalog:",
"isbot": "^5.1.31",
"isbot": "catalog:",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"next-themes": "0.4.6",
"next-themes": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "7.51.5",
"react-hook-form": "catalog:",
"react-router": "catalog:",
"serve": "14.2.5",
"serve": "catalog:",
"swr": "catalog:",
"uuid": "catalog:"
},
@@ -52,6 +52,7 @@
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "catalog:",
"@tailwindcss/postcss": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
@@ -59,6 +60,6 @@
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "catalog:"
}
}
+3 -44
View File
@@ -2,8 +2,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# python imports
import os
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.throttling import SimpleRateThrottle
@@ -11,48 +11,7 @@ from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = "api_key"
rate = os.environ.get("API_KEY_RATE_LIMIT", "60/minute")
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
api_key = request.headers.get("X-Api-Key")
if not api_key:
return None # Allow the request if there's no API key
# Use the API key as part of the cache key
return f"{self.scope}:{api_key}"
def allow_request(self, request, view):
allowed = super().allow_request(request, view)
if allowed:
now = self.timer()
# Calculate the remaining limit and reset time
history = self.cache.get(self.key, [])
# Remove old histories
while history and history[-1] <= now - self.duration:
history.pop()
# Calculate the requests
num_requests = len(history)
# Check available requests
available = self.num_requests - num_requests
# Unix timestamp for when the rate limit will reset
reset_time = int(now + self.duration)
# Add headers
request.META["X-RateLimit-Remaining"] = max(0, available)
request.META["X-RateLimit-Reset"] = reset_time
return allowed
class ServiceTokenRateThrottle(SimpleRateThrottle):
scope = "service_token"
rate = "300/minute"
rate = settings.API_KEY_RATE_LIMIT
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
+4 -2
View File
@@ -59,8 +59,10 @@ class CycleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
project_id = (
self.context.get("project_id")
or self.initial_data.get("project_id")
or (self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None)
)
if not project_id:
+2 -1
View File
@@ -69,7 +69,7 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at", "completed_at"]
exclude = ["description_json", "description_stripped"]
def validate(self, data):
@@ -850,6 +850,7 @@ class IssueExpandSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"completed_at",
]
+13 -6
View File
@@ -114,13 +114,20 @@ class ProjectCreateSerializer(BaseSerializer):
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
raise serializers.ValidationError("Project identifier cannot contain special characters.")
if data.get("project_lead", None) is not None:
# Check if the project lead is a member of the workspace
if not WorkspaceMember.objects.filter(
project_lead = data.get("project_lead")
if (
project_lead
and not WorkspaceMember.objects.filter(
workspace_id=self.context["workspace_id"],
member_id=data.get("project_lead"),
).exists():
raise serializers.ValidationError("Project lead should be a user in the workspace")
member=project_lead,
is_active=True,
).exists()
):
# Field-shaped error so DRF surfaces it under the specific key
# rather than as non_field_errors. Also requires the membership
# to be active so that revoked / removed members can't slip
# through and trigger the FK error downstream.
raise serializers.ValidationError({"project_lead": "The provided user is not a member of this workspace."})
if data.get("default_assignee", None) is not None:
# Check if the default assignee is a member of the workspace
+7
View File
@@ -19,6 +19,7 @@ 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.app.permissions import WorkspaceUserPermission
from plane.api.views.base import BaseAPIView
from plane.api.serializers import (
UserAssetUploadSerializer,
@@ -404,6 +405,12 @@ class UserServerAssetEndpoint(BaseAPIView):
class GenericAssetEndpoint(BaseAPIView):
"""This endpoint is used to upload generic assets that can be later bound to entities."""
# The workspace is taken straight from the URL slug, so every method must
# verify the caller is an active member of that workspace. Without this the
# endpoint is a cross-workspace IDOR (the public-API sibling of the
# CVE-2026-46558 dashboard fix).
permission_classes = [WorkspaceUserPermission]
use_read_replica = True
@asset_docs(
+3 -16
View File
@@ -22,9 +22,8 @@ from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView
# Module imports
from plane.db.models.api import APIToken
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.api.rate_limit import ApiKeyRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin
@@ -60,19 +59,7 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
return queryset
def get_throttles(self):
throttle_classes = []
api_key = self.request.headers.get("X-Api-Key")
if api_key:
service_token = APIToken.objects.filter(token=api_key, is_service=True).first()
if service_token:
throttle_classes.append(ServiceTokenRateThrottle())
return throttle_classes
throttle_classes.append(ApiKeyRateThrottle())
return throttle_classes
return [ApiKeyRateThrottle()]
def handle_exception(self, exc):
"""
@@ -123,7 +110,7 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
return response
def finalize_response(self, request, response, *args, **kwargs):
# Call super to get the default response
+6 -2
View File
@@ -305,7 +305,9 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
serializer = CycleCreateSerializer(
data=request.data, context={"request": request, "project_id": project_id}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -516,7 +518,9 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
serializer = CycleUpdateSerializer(
cycle, data=request.data, partial=True, context={"request": request, "project_id": project_id}
)
if serializer.is_valid():
if (
request.data.get("external_id")
+83 -37
View File
@@ -6,7 +6,7 @@
import json
# Django imports
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -38,6 +38,7 @@ from plane.db.models import (
ProjectPage,
)
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.utils.exception_logger import log_exception
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.api.serializers import (
@@ -223,48 +224,72 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
if serializer.is_valid():
serializer.save()
with transaction.atomic():
serializer.save()
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
# Add the creator as Administrator of the project.
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
request.user.id
):
ProjectMember.objects.create(
project_id=serializer.instance.id,
member_id=serializer.instance.project_lead,
role=20,
# If a different project_lead was provided, add them as
# Administrator too. Use project_lead_id (the FK column)
# rather than project_lead (the related descriptor, which
# would resolve to a User instance and break UUID coercion
# downstream in ProjectMember.objects.create).
if (
serializer.instance.project_lead_id is not None
and serializer.instance.project_lead_id != request.user.id
):
ProjectMember.objects.create(
project_id=serializer.instance.id,
member_id=serializer.instance.project_lead_id,
role=20,
)
State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=serializer.instance,
sequence=state["sequence"],
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
)
for state in DEFAULT_STATES
]
)
State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=serializer.instance,
sequence=state["sequence"],
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
project = self.get_queryset().filter(pk=serializer.instance.id).first()
# Defer the activity-log task until the surrounding
# transaction commits, so it never fires on a rolled-back
# creation.
# robust=True so broker / dispatch failures are logged
# internally by Django and don't surface as 500 after a
# successful commit (the inverse of the rollback path
# covered by test_model_activity_not_called_on_rollback).
# A nested function (rather than functools.partial) is
# used here because Django's robust on_commit logging
# path reads ``func.__qualname__`` to format the error
# message; ``partial`` objects don't have that dunder
# by default and the workaround is brittle when the
# wrapped callable is a mock. The closure captures
# the locals at construction time and they are never
# rebound, so late-binding is not a hazard here.
def _dispatch_model_activity():
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
for state in DEFAULT_STATES
]
)
project = self.get_queryset().filter(pk=serializer.instance.id).first()
# Model activity
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
transaction.on_commit(_dispatch_model_activity, robust=True)
serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -275,6 +300,17 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
# Any other IntegrityError is unexpected: log it the same way
# the catch-all `except Exception` below would and return the
# same generic 500 so the client gets a uniform error shape.
# `raise` here would not fall through to a sibling except
# clause — it would exit the try/except entirely and bypass
# both the logging and the JSON response.
log_exception(e)
return Response(
{"error": "An unexpected error occurred"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Workspace.DoesNotExist:
return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND)
except ValidationError:
@@ -282,6 +318,16 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
)
except Exception as e:
# Unexpected server-side failure: log the traceback and return a
# generic 500 so the client can distinguish it from a 4xx caused
# by bad input. Returning 400 here was the anti-pattern that
# masked the original ghost-create bug.
log_exception(e)
return Response(
{"error": "An unexpected error occurred"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class ProjectDetailAPIEndpoint(BaseAPIView):
+1
View File
@@ -22,6 +22,7 @@ class APITokenSerializer(BaseSerializer):
"is_active",
"last_used",
"user_type",
"allowed_rate_limit",
]
+1
View File
@@ -110,6 +110,7 @@ class IssueCreateSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"completed_at",
]
def to_representation(self, instance):
+2 -2
View File
@@ -44,7 +44,7 @@ class ApiTokenEndpoint(BaseAPIView):
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
api_tokens = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -54,7 +54,7 @@ class ApiTokenEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
+2 -2
View File
@@ -120,7 +120,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
return response
@property
def workspace_slug(self):
@@ -215,7 +215,7 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator
except Exception as exc:
response = self.handle_exception(exc)
return exc
return response
@property
def workspace_slug(self):
+1 -1
View File
@@ -113,7 +113,7 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
estimate = Estimate.objects.get(pk=estimate_id)
estimate = Estimate.objects.get(pk=estimate_id, workspace__slug=slug, project_id=project_id)
if request.data.get("estimate"):
estimate.name = request.data.get("estimate").get("name", estimate.name)
+2 -1
View File
@@ -99,6 +99,7 @@ class IssueListEndpoint(BaseAPIView):
# Apply legacy filters
filters = issue_filters(request.query_params, "GET")
issue_queryset = queryset.filter(**filters)
issue_queryset = issue_queryset.filter(state__deleted_at__isnull=True)
# Add select_related, prefetch_related if fields or expand is not None
if self.fields or self.expand:
@@ -157,7 +158,7 @@ class IssueListEndpoint(BaseAPIView):
)
if self.fields or self.expand:
issues = IssueSerializer(queryset, many=True, fields=self.fields, expand=self.expand).data
issues = IssueSerializer(issue_queryset, many=True, fields=self.fields, expand=self.expand).data
else:
issues = issue_queryset.values(
"id",
+1 -1
View File
@@ -332,7 +332,7 @@ class ProjectViewSet(BaseViewSet):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
project = Project.objects.get(pk=pk, workspace__slug=slug)
intake_view = request.data.get("inbox_view", project.intake_view)
current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder)
if project.archived_at:
+8 -4
View File
@@ -206,11 +206,15 @@ class ProjectMemberViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)
# Fetch the workspace role of the project member
workspace_role = WorkspaceMember.objects.get(
# Fetch the target's workspace role (used to cap the new project role)
target_workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
is_workspace_admin = workspace_role == ROLE.ADMIN.value
# Fetch the requester's workspace role to decide if they may bypass project-role checks
requester_workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user, is_active=True
).role
is_workspace_admin = requester_workspace_role == ROLE.ADMIN.value
# Check if the user is not editing their own role if they are not an admin
if request.user.id == project_member.member_id and not is_workspace_admin:
@@ -251,7 +255,7 @@ class ProjectMemberViewSet(BaseViewSet):
)
# Cannot assign a role higher than the target's workspace role
if workspace_role in [5] and new_role in [15, 20]:
if target_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,
+1 -1
View File
@@ -81,7 +81,7 @@ class WebhookEndpoint(BaseAPIView):
serializer = WebhookSerializer(
webhook,
data=request.data,
context={request: request},
context={"request": request},
partial=True,
fields=(
"id",
+7 -2
View File
@@ -279,11 +279,16 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user, is_active=True
)
# Verify the target user is also an active member of this workspace
# before exposing their profile data.
target_workspace_member = WorkspaceMember.objects.select_related("member").get(
workspace__slug=slug, member_id=user_id, is_active=True
)
user_data = target_workspace_member.member
projects = []
if requesting_workspace_member.role >= 15:
projects = (
+49 -35
View File
@@ -8,10 +8,10 @@ import os
import uuid
from io import BytesIO
import requests
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from plane.utils.url_security import pinned_fetch_following_redirects
# Django imports
from django.utils import timezone
@@ -146,48 +146,62 @@ class Adapter:
try:
headers = self.get_avatar_download_headers()
# Download the avatar image
response = requests.get(avatar_url, timeout=10, headers=headers)
response.raise_for_status()
# Download the avatar image over an SSRF-safe client: the avatar URL
# comes from the OAuth provider's (attacker-influenceable) profile
# data, so it must not be allowed to reach internal addresses. The
# connection is pinned to the validated IP (defeats DNS rebinding)
# and every redirect hop is re-validated, so a public URL cannot
# bounce the fetch to an internal target — GHSA-cv9p-325g-wmv5 /
# GHSA-hx79-5pj5-qh42 (avatar hop).
# stream=True so the body is read incrementally and the size cap
# below actually bounds memory (without it, requests buffers the
# whole body before any check runs).
response, _ = pinned_fetch_following_redirects(
"GET", avatar_url, headers=headers, timeout=10, max_redirects=5, stream=True
)
try:
response.raise_for_status()
# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None
# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)
if not extension:
return None
# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)
# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)
if not extension:
return None
# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)
finally:
response.close()
# Generate unique filename
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
storage = S3Storage(request=self.request)
# Create file-like object
file_obj = BytesIO(response.content)
# Create file-like object from the size-bounded buffer
file_obj = BytesIO(content)
file_obj.seek(0)
# Upload using boto3 directly
@@ -22,6 +22,27 @@ from plane.db.models import User
class MagicCodeProvider(CredentialAdapter):
provider = "magic-code"
# Max wrong-code verification attempts per issued token before the token
# is invalidated. Prevents brute-forcing the 6-digit code space within
# the token TTL window.
MAX_VERIFY_ATTEMPTS = 5
# Atomic INCR + first-time EXPIRE for the verify-attempt counter.
# Using a dedicated counter key with this script makes the increment
# safe under concurrent wrong-code requests; a plain JSON read/modify/
# write would race and let parallel attackers exceed the cap.
_INCREMENT_VERIFY_ATTEMPTS_SCRIPT = (
'local count = redis.call("INCR", KEYS[1]) '
'if count == 1 then '
' redis.call("EXPIRE", KEYS[1], tonumber(ARGV[1])) '
'end '
'return count'
)
@staticmethod
def _verify_attempts_key(token_key):
return f"{token_key}:verify_attempts"
def __init__(self, request, key, code=None, callback=None):
(EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN) = get_configuration_value(
[
@@ -92,6 +113,9 @@ class MagicCodeProvider(CredentialAdapter):
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
# Reset the verify-attempt counter so each newly issued token starts
# with a fresh budget of MAX_VERIFY_ATTEMPTS.
ri.delete(self._verify_attempts_key(key))
return key, token
def set_user_data(self):
@@ -114,12 +138,52 @@ class MagicCodeProvider(CredentialAdapter):
},
}
)
# Delete the token from redis if the code match is successful
# Delete the token and its counter from redis on success.
ri.delete(self.key)
ri.delete(self._verify_attempts_key(self.key))
return
else:
email = str(self.key).replace("magic_", "", 1)
if User.objects.filter(email=email).exists():
user_exists = User.objects.filter(email=email).exists()
# Atomically increment the verify-attempt counter in Redis.
# The Lua script sets the TTL only on the first increment so
# the lockout window matches the remaining token TTL and does
# not get extended by every wrong-code attempt.
# ri.ttl() returns -2 (missing), -1 (no expiry), 0 (sub-second
# remaining; Redis floors to whole seconds), or a positive int.
# Clamp to >=1 because EXPIRE key 0 immediately deletes the key
# and would let an attacker bypass the cap in the final second.
remaining_ttl = ri.ttl(self.key)
if remaining_ttl is None or remaining_ttl <= 0:
remaining_ttl = 1
verify_attempts = int(
ri.eval(
self._INCREMENT_VERIFY_ATTEMPTS_SCRIPT,
1,
self._verify_attempts_key(self.key),
remaining_ttl,
)
)
if verify_attempts >= self.MAX_VERIFY_ATTEMPTS:
# Invalidate the token (and counter) so further attempts
# must regenerate; regeneration is itself attempt-counted.
ri.delete(self.key)
ri.delete(self._verify_attempts_key(self.key))
if user_exists:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN"],
error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN",
payload={"email": str(email)},
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP"],
error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP",
payload={"email": str(email)},
)
if user_exists:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_MAGIC_CODE_SIGN_IN"],
error_message="INVALID_MAGIC_CODE_SIGN_IN",
+22 -1
View File
@@ -2,6 +2,9 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
# Third party imports
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from rest_framework import status
@@ -15,7 +18,9 @@ from plane.authentication.adapter.error import (
class AuthenticationThrottle(AnonRateThrottle):
rate = "30/minute"
# Rate is configurable per-deployment via the AUTHENTICATION_RATE_LIMIT
# env var (DRF format: "<num>/<period>" where period is second/minute/hour/day).
rate = os.environ.get("AUTHENTICATION_RATE_LIMIT", "10/minute")
scope = "authentication"
def throttle_failure_view(self, request, *args, **kwargs):
@@ -28,6 +33,22 @@ class AuthenticationThrottle(AnonRateThrottle):
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
def authentication_throttle_allows(request):
"""
Apply AuthenticationThrottle to a plain django.views.View request.
DRF's throttle_classes only run inside APIView.initial(); the magic
sign-in / sign-up endpoints extend django.views.View to return
HttpResponseRedirect from a form POST flow, so they need a manual
throttle check. Returns True if the request is allowed through,
False if it should be rejected with a RATE_LIMIT_EXCEEDED error.
"""
throttle = AuthenticationThrottle()
# SimpleRateThrottle.allow_request only reads request.META and
# request.user, both available on a plain Django HttpRequest.
return throttle.allow_request(request, None)
class EmailVerificationThrottle(UserRateThrottle):
"""
Throttle for email verification code generation.
@@ -26,7 +26,10 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.authentication.rate_limit import (
AuthenticationThrottle,
authentication_throttle_allows,
)
from plane.utils.path_validator import get_safe_redirect_url
@@ -65,6 +68,18 @@ class MagicSignInEndpoint(View):
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if not authentication_throttle_allows(request):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
error_message="RATE_LIMIT_EXCEEDED",
)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=exc.get_error_dict(),
)
return HttpResponseRedirect(url)
if code == "" or email == "":
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"],
@@ -136,6 +151,18 @@ class MagicSignUpEndpoint(View):
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if not authentication_throttle_allows(request):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
error_message="RATE_LIMIT_EXCEEDED",
)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_app=True),
next_path=next_path,
params=exc.get_error_dict(),
)
return HttpResponseRedirect(url)
if code == "" or email == "":
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"],
@@ -25,12 +25,18 @@ from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.authentication.rate_limit import (
AuthenticationThrottle,
authentication_throttle_allows,
)
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
class MagicGenerateSpaceEndpoint(APIView):
permission_classes = [AllowAny]
throttle_classes = [AuthenticationThrottle]
def post(self, request):
# Check if instance is configured
instance = Instance.objects.first()
@@ -60,6 +66,18 @@ class MagicSignInSpaceEndpoint(View):
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if not authentication_throttle_allows(request):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
error_message="RATE_LIMIT_EXCEEDED",
)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=exc.get_error_dict(),
)
return HttpResponseRedirect(url)
if code == "" or email == "":
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"],
@@ -119,6 +137,18 @@ class MagicSignUpSpaceEndpoint(View):
email = request.POST.get("email", "").strip().lower()
next_path = request.POST.get("next_path")
if not authentication_throttle_allows(request):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
error_message="RATE_LIMIT_EXCEEDED",
)
url = get_safe_redirect_url(
base_url=base_host(request=request, is_space=True),
next_path=next_path,
params=exc.get_error_dict(),
)
return HttpResponseRedirect(url)
if code == "" or email == "":
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"],
+47 -318
View File
@@ -5,19 +5,16 @@
# Python imports
from datetime import timedelta
import logging
from typing import List, Dict, Any, Callable, Optional
import os
from typing import Callable, Iterable
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import F, Window, Subquery
from django.db.models.functions import RowNumber
# Third party imports
from celery import shared_task
from pymongo.errors import BulkWriteError
from pymongo.collection import Collection
from pymongo.operations import InsertOne
# Module imports
from plane.db.models import (
@@ -27,7 +24,6 @@ from plane.db.models import (
IssueDescriptionVersion,
WebhookLog,
)
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
@@ -35,285 +31,75 @@ logger = logging.getLogger("plane.worker")
BATCH_SIZE = 500
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
"""Get MongoDB collection if available, otherwise return None."""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None
try:
mongo_collection = MongoConnection.get_collection(collection_name)
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
return mongo_collection
except Exception as e:
logger.error(f"Failed to get MongoDB collection: {str(e)}")
log_exception(e)
return None
def flush_to_mongo_and_delete(
mongo_collection: Optional[Collection],
buffer: List[Dict[str, Any]],
ids_to_delete: List[int],
model,
mongo_available: bool,
) -> None:
"""
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
"""
if not buffer:
logger.debug("No records to flush - buffer is empty")
return
logger.info(f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete")
mongo_archival_failed = False
# Try to insert into MongoDB if available
if mongo_collection is not None and mongo_available:
try:
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
except BulkWriteError as bwe:
logger.error(f"MongoDB bulk write error: {str(bwe)}")
log_exception(bwe)
mongo_archival_failed = True
# If MongoDB is available and archival failed, log the error and return
if mongo_available and mongo_archival_failed:
logger.error(f"MongoDB archival failed for {len(buffer)} records")
return
# Delete from PostgreSQL - delete() returns (count, {model: count})
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
deleted_count = delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
logger.info(f"Batch flush completed: {deleted_count} records deleted")
def process_cleanup_task(
queryset_func: Callable,
transform_func: Callable[[Dict], Dict],
queryset_func: Callable[[], Iterable],
model,
task_name: str,
collection_name: str,
):
"""
Generic function to process cleanup tasks.
Batch-delete expired rows for the given model from PostgreSQL.
Args:
queryset_func: Function that returns the queryset to process
transform_func: Function to transform each record for MongoDB
model: Django model class
task_name: Name of the task for logging
collection_name: MongoDB collection name
queryset_func: Callable returning an iterable of primary keys to delete.
model: Django model class.
task_name: Name of the task for logging.
"""
logger.info(f"Starting {task_name} cleanup task")
# Get MongoDB collection
mongo_collection = get_mongo_collection(collection_name)
mongo_available = mongo_collection is not None
# Get queryset
queryset = queryset_func()
# Process records in batches
buffer: List[Dict[str, Any]] = []
ids_to_delete: List[int] = []
total_processed = 0
total_deleted = 0
total_batches = 0
batch: list = []
for record in queryset:
# Transform record for MongoDB
buffer.append(transform_func(record))
ids_to_delete.append(record["id"])
# Flush batch when it reaches BATCH_SIZE
if len(buffer) >= BATCH_SIZE:
total_batches += 1
flush_to_mongo_and_delete(
mongo_collection=mongo_collection,
buffer=buffer,
ids_to_delete=ids_to_delete,
model=model,
mongo_available=mongo_available,
)
total_processed += len(buffer)
buffer.clear()
ids_to_delete.clear()
# Process final batch if any records remain
if buffer:
def flush(ids: list) -> None:
nonlocal total_deleted, total_batches
if not ids:
return
total_batches += 1
flush_to_mongo_and_delete(
mongo_collection=mongo_collection,
buffer=buffer,
ids_to_delete=ids_to_delete,
model=model,
mongo_available=mongo_available,
)
total_processed += len(buffer)
try:
# `all_objects` is a plain manager, so this is a hard delete — rows
# are removed from PostgreSQL immediately rather than soft-deleted.
delete_result = model.all_objects.filter(id__in=ids).delete()
deleted = delete_result[0] if isinstance(delete_result, tuple) else 0
total_deleted += deleted
except Exception as e:
# Log and skip a failed batch rather than aborting the whole run, so
# a single bad batch doesn't block cleanup of the remaining rows.
log_exception(e)
for record_id in queryset_func():
batch.append(record_id)
if len(batch) >= BATCH_SIZE:
flush(batch)
batch = []
# Flush the final partial batch
flush(batch)
logger.info(
f"{task_name} cleanup task completed",
extra={
"total_records_processed": total_processed,
"total_batches": total_batches,
"mongo_available": mongo_available,
"collection_name": collection_name,
},
extra={"total_records_deleted": total_deleted, "total_batches": total_batches},
)
# Transform functions for each model
def transform_api_log(record: Dict) -> Dict:
"""Transform API activity log record."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"token_identifier": str(record["token_identifier"]),
"path": record["path"],
"method": record["method"],
"query_params": record.get("query_params"),
"headers": record.get("headers"),
"body": record.get("body"),
"response_code": record["response_code"],
"response_body": record["response_body"],
"ip_address": record["ip_address"],
"user_agent": record["user_agent"],
"created_by_id": str(record["created_by_id"]),
}
def transform_email_log(record: Dict) -> Dict:
"""Transform email notification log record."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"receiver_id": str(record["receiver_id"]),
"triggered_by_id": str(record["triggered_by_id"]),
"entity_identifier": str(record["entity_identifier"]),
"entity_name": record["entity_name"],
"data": record["data"],
"processed_at": (str(record["processed_at"]) if record.get("processed_at") else None),
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
"entity": record["entity"],
"old_value": str(record["old_value"]),
"new_value": str(record["new_value"]),
"created_by_id": str(record["created_by_id"]),
}
def transform_page_version(record: Dict) -> Dict:
"""Transform page version record."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"page_id": str(record["page_id"]),
"workspace_id": str(record["workspace_id"]),
"owned_by_id": str(record["owned_by_id"]),
"description_html": record["description_html"],
"description_binary": record["description_binary"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"sub_pages_data": record["sub_pages_data"],
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
"last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None),
}
def transform_issue_description_version(record: Dict) -> Dict:
"""Transform issue description version record."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"issue_id": str(record["issue_id"]),
"workspace_id": str(record["workspace_id"]),
"project_id": str(record["project_id"]),
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"owned_by_id": str(record["owned_by_id"]),
"last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None),
"description_binary": record["description_binary"],
"description_html": record["description_html"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
}
def transform_webhook_log(record: Dict):
"""Transfer webhook logs to a new destination."""
return {
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"workspace_id": str(record["workspace_id"]),
"webhook": str(record["webhook"]),
# Request
"event_type": str(record["event_type"]),
"request_method": str(record["request_method"]),
"request_headers": str(record["request_headers"]),
"request_body": str(record["request_body"]),
# Response
"response_status": str(record["response_status"]),
"response_body": str(record["response_body"]),
"response_headers": str(record["response_headers"]),
# retry count
"retry_count": str(record["retry_count"]),
}
# Queryset functions for each cleanup task
# Queryset functions for each cleanup task — each yields primary keys to delete
def get_api_logs_queryset():
"""Get API logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
"""Get API activity logs older than the API retention window."""
cutoff_time = timezone.now() - timedelta(days=settings.API_ACTIVITY_LOG_RETENTION_DAYS)
logger.info(f"API logs cutoff time: {cutoff_time}")
return (
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
.values(
"id",
"created_at",
"token_identifier",
"path",
"method",
"query_params",
"headers",
"body",
"response_code",
"response_body",
"ip_address",
"user_agent",
"created_by_id",
)
.values_list("id", flat=True)
.iterator(chunk_size=BATCH_SIZE)
)
def get_email_logs_queryset():
"""Get email logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
"""Get email logs older than the email retention window."""
cutoff_time = timezone.now() - timedelta(days=settings.EMAIL_LOG_RETENTION_DAYS)
logger.info(f"Email logs cutoff time: {cutoff_time}")
return (
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
.values(
"id",
"created_at",
"receiver_id",
"triggered_by_id",
"entity_identifier",
"entity_name",
"data",
"processed_at",
"sent_at",
"entity",
"old_value",
"new_value",
"created_by_id",
)
.values_list("id", flat=True)
.iterator(chunk_size=BATCH_SIZE)
)
@@ -334,22 +120,7 @@ def get_page_versions_queryset():
return (
PageVersion.all_objects.filter(id__in=Subquery(subq))
.values(
"id",
"created_at",
"page_id",
"workspace_id",
"owned_by_id",
"description_html",
"description_binary",
"description_stripped",
"description_json",
"sub_pages_data",
"created_by_id",
"updated_by_id",
"deleted_at",
"last_saved_at",
)
.values_list("id", flat=True)
.iterator(chunk_size=BATCH_SIZE)
)
@@ -370,52 +141,20 @@ def get_issue_description_versions_queryset():
return (
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
.values(
"id",
"created_at",
"issue_id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"owned_by_id",
"last_saved_at",
"description_binary",
"description_html",
"description_stripped",
"description_json",
"deleted_at",
)
.values_list("id", flat=True)
.iterator(chunk_size=BATCH_SIZE)
)
def get_webhook_logs_queryset():
"""Get email logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
"""Get webhook logs older than the webhook retention window."""
cutoff_time = timezone.now() - timedelta(days=settings.WEBHOOK_LOG_RETENTION_DAYS)
logger.info(f"Webhook logs cutoff time: {cutoff_time}")
return (
WebhookLog.all_objects.filter(created_at__lte=cutoff_time)
.values(
"id",
"created_at",
"workspace_id",
"webhook",
"event_type",
# Request
"request_method",
"request_headers",
"request_body",
# Response
"response_status",
"response_body",
"response_headers",
"retry_count",
)
.order_by("created_at")
.iterator(chunk_size=100)
.values_list("id", flat=True)
.iterator(chunk_size=BATCH_SIZE)
)
@@ -424,10 +163,8 @@ def delete_api_logs():
"""Delete old API activity logs."""
process_cleanup_task(
queryset_func=get_api_logs_queryset,
transform_func=transform_api_log,
model=APIActivityLog,
task_name="API Activity Log",
collection_name="api_activity_logs",
)
@@ -436,10 +173,8 @@ def delete_email_notification_logs():
"""Delete old email notification logs."""
process_cleanup_task(
queryset_func=get_email_logs_queryset,
transform_func=transform_email_log,
model=EmailNotificationLog,
task_name="Email Notification Log",
collection_name="email_notification_logs",
)
@@ -448,10 +183,8 @@ def delete_page_versions():
"""Delete excess page versions."""
process_cleanup_task(
queryset_func=get_page_versions_queryset,
transform_func=transform_page_version,
model=PageVersion,
task_name="Page Version",
collection_name="page_versions",
)
@@ -460,20 +193,16 @@ def delete_issue_description_versions():
"""Delete excess issue description versions."""
process_cleanup_task(
queryset_func=get_issue_description_versions_queryset,
transform_func=transform_issue_description_version,
model=IssueDescriptionVersion,
task_name="Issue Description Version",
collection_name="issue_description_versions",
)
@shared_task
def delete_webhook_logs():
"""Delete old webhook logs"""
"""Delete old webhook logs."""
process_cleanup_task(
queryset_func=get_webhook_logs_queryset,
transform_func=transform_webhook_log,
model=WebhookLog,
task_name="Webhook Log",
collection_name="webhook_logs",
)
+9 -68
View File
@@ -4,14 +4,12 @@
# Python imports
import logging
from typing import Optional, Dict, Any
from typing import Dict, Any
# Third party imports
from pymongo.collection import Collection
from celery import shared_task
# Django imports
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
from plane.db.models import APIActivityLog
@@ -19,66 +17,9 @@ from plane.db.models import APIActivityLog
logger = logging.getLogger("plane.worker")
def get_mongo_collection() -> Optional[Collection]:
"""
Returns the MongoDB collection for external API activity logs.
"""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None
try:
return MongoConnection.get_collection("api_activity_logs")
except Exception as e:
logger.error(f"Error getting MongoDB collection: {str(e)}")
log_exception(e)
return None
def safe_decode_body(content: bytes) -> Optional[str]:
"""
Safely decodes request/response body content, handling binary data.
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
Returns None if the content is None or empty.
"""
# If the content is None, return None
if content is None:
return None
# If the content is an empty bytes object, return None
if content == b"":
return None
# Check if content is binary by looking for common binary file signatures
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
def log_to_mongo(log_document: Dict[str, Any]) -> bool:
"""
Logs the request to MongoDB if available.
"""
mongo_collection = get_mongo_collection()
if mongo_collection is None:
logger.error("MongoDB not configured")
return False
try:
mongo_collection.insert_one(log_document)
return True
except Exception as e:
log_exception(e)
return False
def log_to_postgres(log_data: Dict[str, Any]) -> bool:
"""
Fallback to logging to PostgreSQL if MongoDB is unavailable.
Persist an external API request log to PostgreSQL.
"""
try:
APIActivityLog.objects.create(**log_data)
@@ -89,12 +30,12 @@ def log_to_postgres(log_data: Dict[str, Any]) -> bool:
@shared_task
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
"""
Process logs to save to MongoDB or Postgres based on the configuration
def process_logs(log_data: Dict[str, Any], **_: Any) -> None:
"""
Persist external API request logs to PostgreSQL.
if MongoConnection.is_configured():
log_to_mongo(mongo_log)
else:
log_to_postgres(log_data)
The catch-all kwargs keep this task signature compatible with jobs enqueued
by an older release (which passed a `mongo_log` argument), so in-flight tasks
don't fail during a rolling deploy. It can be dropped once no such jobs remain.
"""
log_to_postgres(log_data)
+37 -31
View File
@@ -52,8 +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
from plane.utils.url_security import pinned_fetch
SERIALIZER_MAPPER = {
@@ -102,9 +101,6 @@ def save_webhook_log(
retry_count: int,
event_type: str,
) -> None:
# webhook_logs
mongo_collection = MongoConnection.get_collection("webhook_logs")
log_data = {
"workspace_id": str(webhook.workspace_id),
"webhook": str(webhook.id),
@@ -118,27 +114,12 @@ def save_webhook_log(
"retry_count": retry_count,
}
mongo_save_success = False
if mongo_collection is not None:
try:
# insert the log data into the mongo collection
mongo_collection.insert_one(log_data)
logger.info("Webhook log saved successfully to mongo")
mongo_save_success = True
except Exception as e:
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
mongo_save_success = False
# if the mongo save is not successful, save the log data into the database
if not mongo_save_success:
try:
# insert the log data into the database
WebhookLog.objects.create(**log_data)
logger.info("Webhook log saved successfully to database")
except Exception as e:
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
try:
WebhookLog.objects.create(**log_data)
logger.info("Webhook log saved successfully to database")
except Exception as e:
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
@@ -326,16 +307,22 @@ def webhook_send_task(
return
try:
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
validate_url(
# Resolve + validate the webhook URL and pin the connection to the
# validated IP. Pinning closes the DNS-rebinding TOCTOU (validating the
# name then letting requests re-resolve it lets an attacker swap in an
# internal IP between the two lookups). Redirects are never followed, so
# a 3xx Location cannot bounce the request to an internal address
# (GHSA-mq87-52pf-hm3h / cluster C).
response = pinned_fetch(
"POST",
webhook.url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
headers=headers,
json=payload,
timeout=30,
)
# Send the webhook event
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
# Log the webhook request
save_webhook_log(
webhook=webhook,
@@ -377,6 +364,25 @@ def webhook_send_task(
return
raise requests.RequestException()
except ValueError as e:
# SSRF validation failure (blocked/internal target or unresolvable host).
# Not retryable — record it so the failure is visible to the admin, but
# do not raise (no Celery retry) and do not auto-deactivate (the cause
# may be transient DNS).
save_webhook_log(
webhook=webhook,
request_method=action,
request_headers=headers,
request_body=payload,
response_status=400,
response_headers="",
response_body=f"Webhook URL rejected: {e}",
retry_count=self.request.retries,
event_type=event,
)
logger.warning(f"Webhook {webhook.id} URL rejected: {e}")
return
except Exception as e:
log_exception(e)
return
+26 -37
View File
@@ -17,6 +17,8 @@ from typing import Dict, Any, Tuple
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
from plane.utils.ip_address import is_blocked_ip
from plane.utils.url_security import pinned_fetch, pinned_fetch_following_redirects
logger = logging.getLogger("plane.worker")
@@ -36,30 +38,33 @@ def validate_url_ip(url: str) -> None:
ValueError: If the URL points to a private/internal IP
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: No hostname found")
# Only allow HTTP and HTTPS to prevent file://, gopher://, etc.
if parsed.scheme not in ("http", "https"):
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: No hostname found")
# Resolve hostname to IP addresses — this catches domain names that
# point to internal IPs (e.g. attacker.com -> 169.254.169.254)
try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
except (socket.gaierror, UnicodeError):
# UnicodeError covers IDNA failures raised before the address lookup.
raise ValueError("Hostname could not be resolved")
if not addr_info:
raise ValueError("No IP addresses found for the hostname")
# Check every resolved IP against blocked ranges to prevent SSRF
# Check every resolved IP against blocked ranges to prevent SSRF. The
# actual fetch is pinned to the validated IP (see safe_get), so this acts
# as an early, fail-closed pre-filter.
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:
ip = ipaddress.ip_address(addr[4][0].split("%")[0])
if is_blocked_ip(ip):
raise ValueError("Access to private/internal networks is not allowed")
@@ -72,8 +77,9 @@ def safe_get(
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.
Perform a GET request that resolves, validates and pins every hop to its
validated IP. Prevents SSRF via private/internal targets, DNS rebinding
(TOCTOU) and redirects that bounce to internal addresses.
Args:
url: The URL to fetch
@@ -85,32 +91,16 @@ def safe_get(
Raises:
ValueError: If any URL in the redirect chain points to a private IP
requests.RequestException: On network errors
RuntimeError: If max redirects exceeded
requests.RequestException: On network errors (incl. TooManyRedirects)
"""
validate_url_ip(url)
current_url = url
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
return pinned_fetch_following_redirects(
"GET",
url,
headers=headers,
timeout=timeout,
max_redirects=MAX_REDIRECTS,
)
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]:
"""
@@ -199,14 +189,13 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
# Check if fallback exists (pinned to the validated IP).
try:
validate_url_ip(fallback_url)
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
response = pinned_fetch("HEAD", fallback_url, timeout=2)
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
except (requests.RequestException, ValueError) as e:
log_exception(e, warning=True)
return None
+20 -5
View File
@@ -5,12 +5,13 @@
# Python imports
import os
import logging
from datetime import timedelta
# Third party imports
from celery import Celery
from pythonjsonlogger.jsonlogger import JsonFormatter
from pythonjsonlogger.json import JsonFormatter
from celery.signals import after_setup_logger, after_setup_task_logger
from celery.schedules import crontab
from celery.schedules import crontab, schedule
# Module imports
from plane.settings.redis import redis_instance
@@ -20,6 +21,20 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
ri = redis_instance()
# Configurable metrics push interval (in minutes)
# Default: 360 (6 hours), set to 5 for development/testing
def _get_metrics_push_interval_minutes() -> int:
raw = os.environ.get("METRICS_PUSH_INTERVAL_MINUTES", "360")
try:
value = int(raw)
# Cap at 10,000,000 minutes to prevent timedelta(minutes=...) OverflowError
# on arbitrarily large inputs while still allowing multi-year intervals.
return value if 0 < value <= 10_000_000 else 360
except (ValueError, OverflowError):
return 360
METRICS_PUSH_INTERVAL_MINUTES = _get_metrics_push_interval_minutes()
app = Celery("plane")
# Using a string here means the worker will not have to
@@ -32,9 +47,9 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"), # Every 5 minutes
},
"run-every-6-hours-for-instance-trace": {
"task": "plane.license.bgtasks.tracer.instance_traces",
"schedule": crontab(hour="*/6", minute=0), # Every 6 hours
"push-instance-metrics": {
"task": "plane.license.bgtasks.telemetry_metrics.push_instance_metrics",
"schedule": schedule(run_every=timedelta(minutes=METRICS_PUSH_INTERVAL_MINUTES)),
},
# Occurs once every day
"check-every-day-to-delete-hard-delete": {
+35 -27
View File
@@ -18,12 +18,11 @@ 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.db.mixins import SoftDeletionManager, ChangeTrackerMixin
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
from .description import Description
from plane.db.mixins import ChangeTrackerMixin
from .state import StateGroup
@@ -102,7 +101,9 @@ class IssueManager(SoftDeletionManager):
)
class Issue(ProjectBaseModel):
class Issue(ChangeTrackerMixin, ProjectBaseModel):
TRACKED_FIELDS = ["state_id"]
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
@@ -177,30 +178,8 @@ class Issue(ProjectBaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self.state is None:
try:
from plane.db.models import State
default_state = State.objects.filter(
~models.Q(is_triage=True), project=self.project, default=True
).first()
if default_state is None:
random_state = State.objects.filter(~models.Q(is_triage=True), project=self.project).first()
self.state = random_state
else:
self.state = default_state
except ImportError:
pass
else:
try:
from plane.db.models import State
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
self.completed_at = None
except ImportError:
pass
self._ensure_default_state()
kwargs = self._sync_completed_at(kwargs)
if self._state.adding:
with transaction.atomic():
@@ -246,6 +225,35 @@ class Issue(ProjectBaseModel):
"""Return name of the issue"""
return f"{self.name} <{self.project.name}>"
def _ensure_default_state(self):
"""Assign a default state when none is set."""
if self.state is not None:
return
try:
from plane.db.models import State
default_state = State.objects.filter(~models.Q(is_triage=True), project=self.project, default=True).first()
self.state = default_state or State.objects.filter(~models.Q(is_triage=True), project=self.project).first()
except ImportError as e:
log_exception(e)
def _sync_completed_at(self, kwargs):
"""Update completed_at when state changes. Returns kwargs."""
if not self.state:
return kwargs
if not self._state.adding and not self.has_changed("state_id"):
return kwargs
if self.state.group == StateGroup.COMPLETED.value:
self.completed_at = timezone.now()
else:
self.completed_at = None
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = list(set(update_fields) | {"completed_at"})
return kwargs
class IssueBlocker(ProjectBaseModel):
block = models.ForeignKey(Issue, related_name="blocker_issues", on_delete=models.CASCADE)
+1 -1
View File
@@ -106,7 +106,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
except Exception as exc:
response = self.handle_exception(exc)
return exc
return response
@property
def fields(self):
@@ -0,0 +1,381 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
import logging
from urllib.parse import urlparse
# Third party imports
from celery import shared_task
from django.db.models import Count
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
# Module imports
from plane.utils.otlp_endpoints import get_otlp_grpc_endpoint, get_otlp_http_metrics_url
from plane.license.models import Instance
from plane.db.models import (
User,
Workspace,
Project,
Issue,
Module,
Cycle,
CycleIssue,
ModuleIssue,
Page,
WorkspaceMember,
)
logger = logging.getLogger(__name__)
WORKSPACE_METRICS_LIMIT = 1000
FLUSH_TIMEOUT_MILLIS = 30000
EXPORT_INTERVAL_MILLIS = 20000
def _create_otlp_metric_exporter():
"""
Create OTLP metric exporter based on OTLP_METRICS_PROTOCOL (http or grpc).
Uses shared endpoint helpers so metrics and traces target the same collector.
Default is grpc; override with OTLP_METRICS_PROTOCOL=http if needed.
"""
protocol = (os.environ.get("OTLP_METRICS_PROTOCOL") or "grpc").strip().lower()
if protocol == "grpc":
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter as GrpcOTLPMetricExporter,
)
grpc_endpoint = get_otlp_grpc_endpoint()
insecure = os.environ.get("OTEL_EXPORTER_OTLP_METRICS_INSECURE", "").lower() == "true"
return GrpcOTLPMetricExporter(endpoint=grpc_endpoint, insecure=insecure)
# HTTP fallback
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter as HttpOTLPMetricExporter,
)
return HttpOTLPMetricExporter(endpoint=get_otlp_http_metrics_url())
def _collect_and_push_metrics() -> None:
"""
Collect instance metrics and push them to OTEL collector.
Uses OTEL metrics SDK to push gauge metrics directly to the collector,
replacing the previous span-based tracing approach.
"""
# Check if the instance is registered
instance = Instance.objects.first()
if instance is None:
logger.debug("No instance registered, skipping metrics push")
return
if not instance.is_telemetry_enabled:
logger.debug("Telemetry disabled, skipping metrics push")
return
# Configure OTEL metrics (gRPC default, or HTTP if OTLP_METRICS_PROTOCOL=http)
protocol = (os.environ.get("OTLP_METRICS_PROTOCOL") or "grpc").strip().lower()
export_endpoint = get_otlp_grpc_endpoint() if protocol == "grpc" else get_otlp_http_metrics_url()
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
# Create resource with instance identification for the collector
resource = Resource.create({
"service.name": service_name,
"instance_id": str(instance.instance_id or ""),
"plane.instance.type": "self-hosted",
})
# Configure the OTLP metric exporter (HTTP or gRPC)
logger.info(f"Configuring OTLP exporter: protocol={protocol}, endpoint={export_endpoint}")
exporter = _create_otlp_metric_exporter()
reader = PeriodicExportingMetricReader(
exporter,
export_interval_millis=EXPORT_INTERVAL_MILLIS,
)
# Create a new MeterProvider per execution. Gauges use callbacks that capture
# current DB counts, so we need fresh meters each run. provider.shutdown() in
# finally ensures clean teardown. For a 6-hour periodic task, this overhead is acceptable.
provider = MeterProvider(resource=resource, metric_readers=[reader])
try:
# Get a meter
meter = provider.get_meter(__name__)
# Collect instance-level counts
user_count = User.objects.filter(is_bot=False).count()
workspace_count = Workspace.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.exclude(owned_by__is_bot=True, access=1).count()
# Derive domain from WEB_URL env var (e.g. https://plane.acmecorp.com -> plane.acmecorp.com).
# Prepend "//" for scheme-less values (e.g. "plane.acmecorp.com") so urlparse
# populates netloc correctly instead of treating the host as a path component.
web_url = os.environ.get("WEB_URL", "")
if web_url and "://" not in web_url:
web_url = "//" + web_url
domain = urlparse(web_url).netloc if web_url else ""
# Common attributes for all instance-level metrics
instance_attrs = {
"instance_id": str(instance.instance_id or ""),
"instance_name": str(instance.instance_name or ""),
"current_version": str(instance.current_version or ""),
"latest_version": str(instance.latest_version or ""),
"edition": str(instance.edition or ""),
"domain": domain,
"is_verified": str(instance.is_verified).lower(),
"is_setup_done": str(instance.is_setup_done).lower(),
}
# Create gauge callbacks for instance-level metrics
def users_callback(_options):
yield metrics.Observation(user_count, instance_attrs)
def workspaces_callback(_options):
yield metrics.Observation(workspace_count, instance_attrs)
def projects_callback(_options):
yield metrics.Observation(project_count, instance_attrs)
def issues_callback(_options):
yield metrics.Observation(issue_count, instance_attrs)
def modules_callback(_options):
yield metrics.Observation(module_count, instance_attrs)
def cycles_callback(_options):
yield metrics.Observation(cycle_count, instance_attrs)
def cycle_issues_callback(_options):
yield metrics.Observation(cycle_issue_count, instance_attrs)
def module_issues_callback(_options):
yield metrics.Observation(module_issue_count, instance_attrs)
def pages_callback(_options):
yield metrics.Observation(page_count, instance_attrs)
# Register observable gauges for instance metrics
meter.create_observable_gauge(
name="plane_instance_users_total",
description="Total number of users in the Plane instance",
callbacks=[users_callback],
)
meter.create_observable_gauge(
name="plane_instance_workspaces_total",
description="Total number of workspaces",
callbacks=[workspaces_callback],
)
meter.create_observable_gauge(
name="plane_instance_projects_total",
description="Total number of projects across all workspaces",
callbacks=[projects_callback],
)
meter.create_observable_gauge(
name="plane_instance_issues_total",
description="Total number of issues across all projects",
callbacks=[issues_callback],
)
meter.create_observable_gauge(
name="plane_instance_modules_total",
description="Total number of modules",
callbacks=[modules_callback],
)
meter.create_observable_gauge(
name="plane_instance_cycles_total",
description="Total number of cycles",
callbacks=[cycles_callback],
)
meter.create_observable_gauge(
name="plane_instance_cycle_issues_total",
description="Total number of issues in cycles",
callbacks=[cycle_issues_callback],
)
meter.create_observable_gauge(
name="plane_instance_module_issues_total",
description="Total number of issues in modules",
callbacks=[module_issues_callback],
)
meter.create_observable_gauge(
name="plane_instance_pages_total",
description="Total number of pages",
callbacks=[pages_callback],
)
# Collect workspace-level metrics (limited to WORKSPACE_METRICS_LIMIT).
# Fetch workspaces in a deterministic order so the slice is stable across runs.
# Counts are batched into 6 aggregation queries instead of 6×N per-workspace
# queries (avoids N+1 at scale when WORKSPACE_METRICS_LIMIT is large).
instance_id_str = str(instance.instance_id or "")
workspaces = list(Workspace.objects.order_by("created_at")[:WORKSPACE_METRICS_LIMIT])
workspace_ids = [ws.id for ws in workspaces]
project_counts = dict(
Project.objects.filter(workspace_id__in=workspace_ids)
.values("workspace_id")
.annotate(count=Count("id"))
.values_list("workspace_id", "count")
)
issue_counts = dict(
Issue.objects.filter(workspace_id__in=workspace_ids)
.values("workspace_id")
.annotate(count=Count("id"))
.values_list("workspace_id", "count")
)
module_counts = dict(
Module.objects.filter(workspace_id__in=workspace_ids)
.values("workspace_id")
.annotate(count=Count("id"))
.values_list("workspace_id", "count")
)
cycle_counts = dict(
Cycle.objects.filter(workspace_id__in=workspace_ids)
.values("workspace_id")
.annotate(count=Count("id"))
.values_list("workspace_id", "count")
)
member_counts = dict(
WorkspaceMember.objects.filter(workspace_id__in=workspace_ids)
.values("workspace_id")
.annotate(count=Count("id"))
.values_list("workspace_id", "count")
)
page_counts = dict(
Page.objects.filter(workspace_id__in=workspace_ids)
.exclude(owned_by__is_bot=True, access=1)
.values("workspace_id")
.annotate(count=Count("id"))
.values_list("workspace_id", "count")
)
workspace_metrics = []
for workspace in workspaces:
ws_id = workspace.id
workspace_metrics.append({
"instance_id": instance_id_str,
"workspace_id": str(ws_id),
"workspace_slug": str(workspace.slug),
"project_count": project_counts.get(ws_id, 0),
"issue_count": issue_counts.get(ws_id, 0),
"module_count": module_counts.get(ws_id, 0),
"cycle_count": cycle_counts.get(ws_id, 0),
"member_count": member_counts.get(ws_id, 0),
"page_count": page_counts.get(ws_id, 0),
})
def _ws_attrs(ws: dict) -> dict:
return {
"workspace_id": ws["workspace_id"],
"workspace_slug": ws["workspace_slug"],
"instance_id": ws["instance_id"],
}
# Create callbacks for workspace-level metrics
def ws_projects_callback(_options):
for ws in workspace_metrics:
yield metrics.Observation(ws["project_count"], _ws_attrs(ws))
def ws_issues_callback(_options):
for ws in workspace_metrics:
yield metrics.Observation(ws["issue_count"], _ws_attrs(ws))
def ws_modules_callback(_options):
for ws in workspace_metrics:
yield metrics.Observation(ws["module_count"], _ws_attrs(ws))
def ws_cycles_callback(_options):
for ws in workspace_metrics:
yield metrics.Observation(ws["cycle_count"], _ws_attrs(ws))
def ws_members_callback(_options):
for ws in workspace_metrics:
yield metrics.Observation(ws["member_count"], _ws_attrs(ws))
def ws_pages_callback(_options):
for ws in workspace_metrics:
yield metrics.Observation(ws["page_count"], _ws_attrs(ws))
# Register observable gauges for workspace metrics
meter.create_observable_gauge(
name="plane_workspace_projects_total",
description="Number of projects per workspace",
callbacks=[ws_projects_callback],
)
meter.create_observable_gauge(
name="plane_workspace_issues_total",
description="Number of issues per workspace",
callbacks=[ws_issues_callback],
)
meter.create_observable_gauge(
name="plane_workspace_modules_total",
description="Number of modules per workspace",
callbacks=[ws_modules_callback],
)
meter.create_observable_gauge(
name="plane_workspace_cycles_total",
description="Number of cycles per workspace",
callbacks=[ws_cycles_callback],
)
meter.create_observable_gauge(
name="plane_workspace_members_total",
description="Number of members per workspace",
callbacks=[ws_members_callback],
)
meter.create_observable_gauge(
name="plane_workspace_pages_total",
description="Number of pages per workspace",
callbacks=[ws_pages_callback],
)
# Force a synchronous flush to ensure all metrics are exported
# force_flush() blocks until all metrics are exported or timeout is reached
flush_success = provider.force_flush(timeout_millis=FLUSH_TIMEOUT_MILLIS)
if flush_success:
logger.info(
f"Successfully pushed metrics to OTEL collector at {export_endpoint} "
f"for instance {instance.instance_id}"
)
else:
logger.warning(
f"Metrics flush timed out for instance {instance.instance_id}, "
f"some metrics may not have been exported"
)
except Exception as e:
logger.exception(f"Error pushing metrics to OTEL collector: {e}")
# Don't re-raise: allow task to complete gracefully so it retries on next scheduled run
finally:
# Shutdown the provider to clean up resources
provider.shutdown()
@shared_task
def push_instance_metrics():
"""
Celery task to push instance metrics to OTEL collector.
Replaces the previous span-based tracing approach with OTLP metrics gauges.
Scheduled to run every 6 hours via Celery beat.
"""
logger.debug("Starting push_instance_metrics task")
try:
_collect_and_push_metrics()
logger.debug("Completed push_instance_metrics task")
except Exception as e:
logger.exception(f"Failed to push instance metrics: {e}")
-105
View File
@@ -1,105 +0,0 @@
# 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 celery import shared_task
from opentelemetry import trace
# Module imports
from plane.license.models import Instance
from plane.db.models import (
User,
Workspace,
Project,
Issue,
Module,
Cycle,
CycleIssue,
ModuleIssue,
Page,
WorkspaceMember,
)
from plane.utils.telemetry import init_tracer, shutdown_tracer
@shared_task
def instance_traces():
try:
init_tracer()
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then return
if instance is None:
return
if instance.is_telemetry_enabled:
# Get the tracer
tracer = trace.get_tracer(__name__)
# Instance details
with tracer.start_as_current_span("instance_details") as span:
# Count of all models
workspace_count = Workspace.objects.count()
user_count = User.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.count()
# Set span attributes
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("instance_name", instance.instance_name)
span.set_attribute("current_version", instance.current_version)
span.set_attribute("latest_version", instance.latest_version)
span.set_attribute("is_telemetry_enabled", instance.is_telemetry_enabled)
span.set_attribute("is_support_required", instance.is_support_required)
span.set_attribute("is_setup_done", instance.is_setup_done)
span.set_attribute("is_signup_screen_visited", instance.is_signup_screen_visited)
span.set_attribute("is_verified", instance.is_verified)
span.set_attribute("edition", instance.edition)
span.set_attribute("domain", instance.domain)
span.set_attribute("is_test", instance.is_test)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
project_count = Project.objects.filter(workspace=workspace).count()
issue_count = Issue.objects.filter(workspace=workspace).count()
module_count = Module.objects.filter(workspace=workspace).count()
cycle_count = Cycle.objects.filter(workspace=workspace).count()
cycle_issue_count = CycleIssue.objects.filter(workspace=workspace).count()
module_issue_count = ModuleIssue.objects.filter(workspace=workspace).count()
page_count = Page.objects.filter(workspace=workspace).count()
member_count = WorkspaceMember.objects.filter(workspace=workspace).count()
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
span.set_attribute("member_count", member_count)
return
finally:
# Shutdown the tracer
shutdown_tracer()
@@ -15,7 +15,7 @@ from django.utils import timezone
# Module imports
from plane.license.models import Instance, InstanceEdition
from plane.license.bgtasks.tracer import instance_traces
from plane.license.bgtasks.telemetry_metrics import push_instance_metrics
class Command(BaseCommand):
@@ -86,7 +86,7 @@ class Command(BaseCommand):
instance.edition = InstanceEdition.PLANE_COMMUNITY.value
instance.save()
# Call the instance traces task
instance_traces.delay()
# Push instance metrics on registration
push_instance_metrics.delay()
return
+27 -18
View File
@@ -3,12 +3,14 @@
# See the LICENSE file for details.
# Python imports
import hashlib
import hmac
import logging
import time
# Django imports
from django.conf import settings
from django.http import HttpRequest
from django.utils import timezone
# Third party imports
from rest_framework.request import Request
@@ -77,7 +79,7 @@ class RequestLoggerMiddleware:
class APITokenLogMiddleware:
"""
Middleware to log External API requests to MongoDB or PostgreSQL.
Middleware to log External API requests to PostgreSQL.
"""
def __init__(self, get_response):
@@ -111,6 +113,20 @@ class APITokenLogMiddleware:
except UnicodeDecodeError:
return "[Could not decode content]"
# Headers whose values must never be persisted in plaintext logs
SENSITIVE_HEADERS = frozenset({"x-api-key", "authorization", "cookie"})
def _redacted_headers(self, request):
"""
Returns the request headers as a string with sensitive values redacted,
so that credentials such as the API key are never stored in plaintext.
"""
redacted = {
key: ("[REDACTED]" if key.lower() in self.SENSITIVE_HEADERS else value)
for key, value in request.headers.items()
}
return str(redacted)
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
@@ -121,32 +137,25 @@ class APITokenLogMiddleware:
try:
log_data = {
"token_identifier": api_key,
# Tokenize the (high-entropy) API key into a stable, non-reversible
# identifier so logs can be correlated to a token without ever
# persisting the raw key. A keyed HMAC is used rather than a bare
# hash so the digest cannot be precomputed from a known key value.
"token_identifier": hmac.new(
settings.SECRET_KEY.encode(), api_key.encode(), hashlib.sha256
).hexdigest(),
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": str(request.headers),
"headers": self._redacted_headers(request),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
}
user_id = (
str(request.user.id)
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
else None
)
# Additional fields for MongoDB
mongo_log = {
**log_data,
"created_at": timezone.now(),
"updated_at": timezone.now(),
"created_by": user_id,
"updated_by": user_id,
}
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
process_logs.delay(log_data=log_data)
except Exception as e:
log_exception(e)
+32 -6
View File
@@ -132,6 +132,9 @@ REST_FRAMEWORK = {
"SCHEMA_COERCE_PATH_PK": False,
}
# API key throttle rate (DRF SimpleRateThrottle format, e.g. "60/minute")
API_KEY_RATE_LIMIT = os.environ.get("API_KEY_RATE_LIMIT", "60/minute")
# Django Auth Backend
AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default
@@ -264,7 +267,6 @@ MEDIA_URL = "/media/"
# Internationalization
LANGUAGE_CODE = "en-us"
USE_I18N = True
USE_L10N = True
# Timezones
USE_TZ = True
@@ -322,7 +324,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.cleanup_task",
"plane.license.bgtasks.tracer",
"plane.license.bgtasks.telemetry_metrics",
# management tasks
"plane.bgtasks.dummy_data_task",
# issue version tasks
@@ -403,6 +405,34 @@ WEB_URL = os.environ.get("WEB_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
def _retention_days(env_var, default):
"""
Read a retention window (in days) from the environment, falling back to the
default when the variable is unset, unparseable, or negative — a negative
window would otherwise select rows with a future cutoff and delete everything.
"""
raw = os.environ.get(env_var)
if raw is None:
return default
try:
days = int(raw)
except ValueError:
return default
return days if days >= 0 else default
# API activity logs hold request/response payloads, so they are retained for a
# shorter window than other logs.
API_ACTIVITY_LOG_RETENTION_DAYS = _retention_days("API_ACTIVITY_LOG_RETENTION_DAYS", 14)
# Webhook delivery logs are retained on their own window, independent of the
# generic HARD_DELETE_AFTER_DAYS.
WEBHOOK_LOG_RETENTION_DAYS = _retention_days("WEBHOOK_LOG_RETENTION_DAYS", 14)
# Email notification logs are retained on their own window.
EMAIL_LOG_RETENTION_DAYS = _retention_days("EMAIL_LOG_RETENTION_DAYS", 7)
# Instance Changelog URL
INSTANCE_CHANGELOG_URL = os.environ.get("INSTANCE_CHANGELOG_URL", "")
@@ -505,7 +535,3 @@ if ENABLE_DRF_SPECTACULAR:
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
INSTALLED_APPS.append("drf_spectacular")
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
# MongoDB Settings
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
+1 -6
View File
@@ -46,7 +46,7 @@ LOGGING = {
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"()": "pythonjsonlogger.json.JsonFormatter",
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
},
},
@@ -75,11 +75,6 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.authentication": {
"level": "INFO",
"handlers": ["console"],
-126
View File
@@ -1,126 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.conf import settings
import logging
# Third party imports
from pymongo import MongoClient
from pymongo.database import Database
from pymongo.collection import Collection
from typing import Optional, TypeVar, Type
T = TypeVar("T", bound="MongoConnection")
# Set up logger
logger = logging.getLogger("plane.mongo")
class MongoConnection:
"""
A singleton class that manages MongoDB connections.
This class ensures only one MongoDB connection is maintained throughout the application.
It provides methods to access the MongoDB client, database, and collections.
Attributes:
_instance (Optional[MongoConnection]): The singleton instance of this class
_client (Optional[MongoClient]): The MongoDB client instance
_db (Optional[Database]): The MongoDB database instance
"""
_instance: Optional["MongoConnection"] = None
_client: Optional[MongoClient] = None
_db: Optional[Database] = None
def __new__(cls: Type[T]) -> T:
"""
Creates a new instance of MongoConnection if one doesn't exist.
Returns:
MongoConnection: The singleton instance
"""
if cls._instance is None:
cls._instance = super(MongoConnection, cls).__new__(cls)
try:
mongo_url = getattr(settings, "MONGO_DB_URL", None)
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
if not mongo_url or not mongo_db_database:
logger.warning(
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
)
return cls._instance
cls._client = MongoClient(mongo_url)
cls._db = cls._client[mongo_db_database]
# Test the connection
cls._client.server_info()
logger.info("MongoDB connection established successfully")
except Exception as e:
logger.warning(
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
)
return cls._instance
@classmethod
def get_client(cls) -> Optional[MongoClient]:
"""
Returns the MongoDB client instance.
Returns:
Optional[MongoClient]: The MongoDB client instance or None if not configured
"""
if cls._client is None:
cls._instance = cls()
return cls._client
@classmethod
def get_db(cls) -> Optional[Database]:
"""
Returns the MongoDB database instance.
Returns:
Optional[Database]: The MongoDB database instance or None if not configured
"""
if cls._db is None:
cls._instance = cls()
return cls._db
@classmethod
def get_collection(cls, collection_name: str) -> Optional[Collection]:
"""
Returns a MongoDB collection by name.
Args:
collection_name (str): The name of the collection to retrieve
Returns:
Optional[Collection]: The MongoDB collection instance or None if not configured
"""
try:
db = cls.get_db()
if db is None:
logger.warning(f"Cannot access collection '{collection_name}': MongoDB not configured")
return None
return db[collection_name]
except Exception as e:
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
return None
@classmethod
def is_configured(cls) -> bool:
"""
Check if MongoDB is properly configured and connected.
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
if cls._client is None:
cls._instance = cls()
return cls._client is not None and cls._db is not None
+1 -6
View File
@@ -34,7 +34,7 @@ LOGGING = {
"formatters": {
"verbose": {"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"()": "pythonjsonlogger.json.JsonFormatter",
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
},
},
@@ -85,11 +85,6 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.authentication": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
+2 -2
View File
@@ -114,7 +114,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
return response
except Exception as exc:
response = self.handle_exception(exc)
return exc
return response
@property
def workspace_slug(self):
@@ -197,7 +197,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
except Exception as exc:
response = self.handle_exception(exc)
return exc
return response
@property
def workspace_slug(self):
+4 -4
View File
@@ -91,7 +91,7 @@ When writing tests, follow these guidelines:
- For web app API (`/api/`), use `session_client`
- For smoke tests with real HTTP, use `plane_server`
3. Use the correct URL namespace when reverse-resolving URLs:
- For external API, use `reverse("api:endpoint_name")`
- For external API, use `reverse("api:endpoint_name")`
- For web app API, use `reverse("endpoint_name")`
4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database.
5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests.
@@ -101,7 +101,7 @@ When writing tests, follow these guidelines:
Common fixtures are defined in:
- `conftest.py`: General fixtures for authentication, database access, etc.
- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB)
- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery)
- `factories.py`: Test factories for easy model instance creation
## Best Practices
@@ -125,7 +125,7 @@ When writing tests, follow these guidelines:
Tests for components that interact with external services should:
1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests.
1. Use the `mock_redis`, `mock_elasticsearch`, and `mock_celery` fixtures for unit and most contract tests.
2. For more comprehensive contract tests, use Docker-based test containers (optional).
## Coverage Reports
@@ -140,4 +140,4 @@ This creates an HTML report in the `htmlcov/` directory.
## Migration from Old Tests
Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories.
Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories.
-35
View File
@@ -51,41 +51,6 @@ def mock_elasticsearch():
yield mock_es_client
@pytest.fixture
def mock_mongodb():
"""
Mock MongoDB for testing without actual MongoDB connection.
This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client.
"""
# Create mock MongoDB clients and collections
mock_mongo_client = MagicMock()
mock_mongo_db = MagicMock()
mock_mongo_collection = MagicMock()
# Set up the chain: client -> database -> collection
mock_mongo_client.__getitem__.return_value = mock_mongo_db
mock_mongo_client.get_database.return_value = mock_mongo_db
mock_mongo_db.__getitem__.return_value = mock_mongo_collection
# Configure common MongoDB collection operations
mock_mongo_collection.find_one.return_value = None
mock_mongo_collection.find.return_value = MagicMock(__iter__=lambda x: iter([]), count=lambda: 0)
mock_mongo_collection.insert_one.return_value = MagicMock(inserted_id="mock_id_123", acknowledged=True)
mock_mongo_collection.insert_many.return_value = MagicMock(
inserted_ids=["mock_id_123", "mock_id_456"], acknowledged=True
)
mock_mongo_collection.update_one.return_value = MagicMock(modified_count=1, matched_count=1, acknowledged=True)
mock_mongo_collection.update_many.return_value = MagicMock(modified_count=2, matched_count=2, acknowledged=True)
mock_mongo_collection.delete_one.return_value = MagicMock(deleted_count=1, acknowledged=True)
mock_mongo_collection.delete_many.return_value = MagicMock(deleted_count=2, acknowledged=True)
mock_mongo_collection.count_documents.return_value = 0
# Start the patch
with patch("pymongo.MongoClient", return_value=mock_mongo_client):
yield mock_mongo_client
@pytest.fixture
def mock_celery():
"""
@@ -19,6 +19,7 @@ def project(db, workspace, create_user):
identifier="TP",
workspace=workspace,
created_by=create_user,
cycle_view=True,
)
ProjectMember.objects.create(
project=project,
@@ -0,0 +1,143 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the public REST API ``GenericAssetEndpoint``.
Regression coverage for the cross-workspace asset IDOR (the unfixed
external-API sibling of CVE-2026-46558 / GHSA-qw87-v5w3-6vxx). The endpoint
must reject any caller that is not an active member of the workspace named in
the URL slug, regardless of the workspace their Personal Access Token came
from.
"""
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import FileAsset, User, Workspace, WorkspaceMember
@pytest.fixture
def victim_user(db):
"""A user that owns a separate workspace the attacker is not part of."""
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"victim-{unique_id}@plane.so",
username=f"victim_{unique_id}",
first_name="Victim",
last_name="User",
)
user.set_password("test-password")
user.save()
return user
@pytest.fixture
def victim_workspace(db, victim_user):
"""A workspace whose only active member is ``victim_user``.
The attacker (``create_user``, who authenticates ``api_key_client``) is
deliberately NOT a member here.
"""
workspace = Workspace.objects.create(
name="Victim Workspace",
owner=victim_user,
slug="victim-workspace",
)
WorkspaceMember.objects.create(workspace=workspace, member=victim_user, role=20)
return workspace
@pytest.fixture
def victim_asset(db, victim_workspace, victim_user):
"""An uploaded attachment that lives inside the victim workspace.
``storage_metadata`` is pre-populated so the PATCH handler does not enqueue
the metadata Celery task during the test.
"""
return FileAsset.objects.create(
attributes={"name": "secret.pdf", "type": "application/pdf", "size": 1024},
asset=f"{victim_workspace.id}/secret.pdf",
size=1024,
workspace=victim_workspace,
created_by=victim_user,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
storage_metadata={"size": 1024},
)
@pytest.mark.contract
class TestGenericAssetCrossWorkspaceIDOR:
"""A PAT holder must not reach assets in a workspace they don't belong to."""
def detail_url(self, slug, asset_id):
return f"/api/v1/workspaces/{slug}/assets/{asset_id}/"
def list_url(self, slug):
return f"/api/v1/workspaces/{slug}/assets/"
@pytest.mark.django_db
def test_get_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset):
"""GET on another workspace's asset must be forbidden, not return a
presigned download URL."""
url = self.detail_url(victim_workspace.slug, victim_asset.id)
with mock.patch("plane.api.views.asset.S3Storage") as mock_storage:
mock_storage.return_value.generate_presigned_url.return_value = "https://signed.example/download"
response = api_key_client.get(url)
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
# The S3 download URL must never be minted for a non-member.
mock_storage.return_value.generate_presigned_url.assert_not_called()
@pytest.mark.django_db
def test_post_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace):
"""POST (upload) into another workspace must be forbidden and must not
plant an asset row in the victim workspace."""
url = self.list_url(victim_workspace.slug)
payload = {"name": "evil.pdf", "type": "application/pdf", "size": 1024}
with mock.patch("plane.api.views.asset.S3Storage") as mock_storage:
mock_storage.return_value.generate_presigned_post.return_value = {"url": "x", "fields": {}}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
assert FileAsset.objects.filter(workspace=victim_workspace).count() == 0
@pytest.mark.django_db
def test_patch_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset):
"""PATCH on another workspace's asset must be forbidden and must leave
the asset untouched."""
url = self.detail_url(victim_workspace.slug, victim_asset.id)
response = api_key_client.patch(url, {"is_uploaded": False}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}"
victim_asset.refresh_from_db()
assert victim_asset.is_uploaded is True
@pytest.mark.django_db
def test_member_can_patch_own_workspace_asset(self, api_key_client, workspace, create_user):
"""Positive control: an active member of the workspace can still update
their own asset, so the fix does not over-block legitimate callers."""
asset = FileAsset.objects.create(
attributes={"name": "mine.pdf", "type": "application/pdf", "size": 10},
asset=f"{workspace.id}/mine.pdf",
size=10,
workspace=workspace,
created_by=create_user,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=False,
storage_metadata={"size": 10},
)
url = self.detail_url(workspace.slug, asset.id)
response = api_key_client.patch(url, {"is_uploaded": True}, format="json")
assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}"
asset.refresh_from_db()
assert asset.is_uploaded is True
@@ -0,0 +1,216 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import Project, ProjectMember, State, User, WorkspaceMember
@pytest.fixture
def other_workspace_member(db, workspace):
"""Create another user that is a member of the workspace, distinct from the creator."""
unique_id = uuid4().hex[:8]
other = User.objects.create(
email=f"other-{unique_id}@plane.so",
username=f"other_user_{unique_id}",
first_name="Other",
last_name="User",
)
other.set_password("test-password")
other.save()
WorkspaceMember.objects.create(workspace=workspace, member=other, role=20)
return other
@pytest.fixture
def outsider_user(db):
"""Create a user that is NOT a member of any workspace under test."""
unique_id = uuid4().hex[:8]
outsider = User.objects.create(
email=f"outsider-{unique_id}@plane.so",
username=f"outsider_{unique_id}",
first_name="Out",
last_name="Sider",
)
outsider.set_password("test-password")
outsider.save()
return outsider
@pytest.mark.contract
class TestProjectListCreateAPIEndpoint:
"""Contract tests for POST /api/v1/workspaces/{slug}/projects/."""
def get_url(self, workspace_slug):
return f"/api/v1/workspaces/{workspace_slug}/projects/"
@pytest.mark.django_db
def test_create_project_with_lead_as_creator(self, api_key_client, workspace, create_user):
"""Regression for the ghost-create bug.
When project_lead points to the creator's own user_id, the endpoint
must return 201 and create a fully-populated project (single
ProjectMember as admin, default workflow states).
Before the fix, the endpoint returned 400 "Please provide valid detail"
but had already persisted the Project row without states or members,
leaving an unusable orphan.
"""
url = self.get_url(workspace.slug)
payload = {
"name": "Self Lead Project",
"identifier": "SL",
"project_lead": str(create_user.id),
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
# Look up the project we just created instead of relying on
# ordering-sensitive Project.objects.first().
project = Project.objects.get(id=response.data["id"])
# Creator is registered as admin (single membership; lead == creator
# should not produce a duplicate row).
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
# Default workflow states must be created.
assert State.objects.filter(project=project).count() == 5
@pytest.mark.django_db
def test_create_project_with_lead_as_other_user(
self, api_key_client, workspace, create_user, other_workspace_member
):
"""When project_lead is a different workspace member, both creator
and lead become admins of the project."""
url = self.get_url(workspace.slug)
payload = {
"name": "Other Lead Project",
"identifier": "OL",
"project_lead": str(other_workspace_member.id),
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
project = Project.objects.get(id=response.data["id"])
# Both creator and other_workspace_member are admins.
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).exists()
assert ProjectMember.objects.filter(project=project, member=other_workspace_member, role=20).exists()
assert State.objects.filter(project=project).count() == 5
@pytest.mark.django_db
def test_create_project_without_lead(self, api_key_client, workspace, create_user):
"""Baseline regression: omitting project_lead must succeed and the
creator becomes the sole admin."""
url = self.get_url(workspace.slug)
payload = {
"name": "Basic Project",
"identifier": "BP",
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
project = Project.objects.get(id=response.data["id"])
assert ProjectMember.objects.filter(project=project, member=create_user, role=20).count() == 1
assert State.objects.filter(project=project).count() == 5
@pytest.mark.django_db
def test_create_project_with_lead_not_in_workspace_returns_400(self, api_key_client, workspace, outsider_user):
"""When project_lead refers to a user that is NOT a member of the
target workspace, the endpoint must reject the request with a 400
carrying a field-shaped error and must not persist the Project."""
url = self.get_url(workspace.slug)
payload = {
"name": "Outsider Lead Project",
"identifier": "OUT",
"project_lead": str(outsider_user.id),
}
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST, f"Got {response.status_code}: {response.data!r}"
assert "project_lead" in response.data, (
f"Expected field-shaped error under 'project_lead', got {response.data!r}"
)
# No project should have been persisted.
assert Project.objects.count() == 0
@pytest.mark.django_db
def test_model_activity_not_called_on_rollback(self, api_key_client, workspace, create_user):
"""If anything inside the transaction.atomic() block raises, the
whole creation must roll back (no Project, no ProjectMember, no
State) and the deferred model_activity.delay() task must not fire,
because it is registered with transaction.on_commit().
Force the failure inside State.objects.bulk_create — past the point
where the original ghost-create bug would have committed a partial
Project — and verify the response is 500 with no side effects.
"""
url = self.get_url(workspace.slug)
payload = {
"name": "Rollback Probe",
"identifier": "RB",
"project_lead": str(create_user.id),
}
forced_error = RuntimeError("forced failure for rollback test")
with (
mock.patch(
"plane.api.views.project.State.objects.bulk_create",
side_effect=forced_error,
),
mock.patch("plane.api.views.project.model_activity") as mocked_activity,
):
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR, (
f"Got {response.status_code}: {response.data!r}"
)
# Transaction must have rolled back: no Project, no ProjectMember,
# no State persisted.
assert Project.objects.count() == 0
assert ProjectMember.objects.count() == 0
assert State.objects.count() == 0
# And the deferred Celery task must not have been dispatched —
# transaction.on_commit() callbacks only fire on a successful commit.
mocked_activity.delay.assert_not_called()
@pytest.mark.django_db(transaction=True)
def test_response_still_201_when_broker_dispatch_fails(self, api_key_client, workspace, create_user):
"""If model_activity.delay raises *after* the atomic block has
committed (e.g., the Celery broker is down), the project, member
rows and states are already persisted — the response must remain
201 and the failure must be absorbed by Django's robust=True
on_commit handling, not surface as a 500.
Uses ``transaction=True`` so the surrounding test transaction is
actually committed and the ``on_commit`` callback fires (the
default ``django_db`` wrapper would suppress it via rollback)."""
url = self.get_url(workspace.slug)
payload = {
"name": "Broker Down",
"identifier": "BD",
"project_lead": str(create_user.id),
}
with mock.patch("plane.api.views.project.model_activity") as mocked_activity:
mocked_activity.delay.side_effect = RuntimeError("broker unavailable")
response = api_key_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
# Project and its scaffolding are persisted (commit happened
# before the on_commit callback fired).
project = Project.objects.get(id=response.data["id"])
assert ProjectMember.objects.filter(project=project).count() == 1
assert State.objects.filter(project=project).count() == 5
# The dispatch was attempted but its failure was swallowed by
# transaction.on_commit(robust=True).
mocked_activity.delay.assert_called_once()
@@ -366,6 +366,23 @@ class TestApiTokenEndpoint:
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.user_type == 0
@pytest.mark.django_db
def test_patch_cannot_modify_allowed_rate_limit(self, session_client, create_user, create_api_token_for_user):
"""Test that allowed_rate_limit cannot be modified via PATCH"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
original_rate_limit = create_api_token_for_user.allowed_rate_limit
update_data = {"allowed_rate_limit": "100000/min"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.allowed_rate_limit == original_rate_limit
@pytest.mark.django_db
def test_patch_cannot_modify_service_token(self, session_client, create_user):
"""Test that service tokens cannot be modified through user token endpoint"""
@@ -5,6 +5,7 @@
import json
import uuid
import pytest
from django.core.cache import cache
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
@@ -12,6 +13,8 @@ from django.test import Client
from django.core.exceptions import ValidationError
from unittest.mock import patch
from plane.authentication.provider.credentials.magic_code import MagicCodeProvider
from plane.authentication.rate_limit import AuthenticationThrottle
from plane.db.models import User
from plane.settings.redis import redis_instance
from plane.license.models import Instance
@@ -302,9 +305,10 @@ class TestMagicSignIn:
user_data = json.loads(ri.get("magic_user@plane.so"))
token = user_data["token"]
# Use Django client to test the redirect flow without following redirects
# Use Django client to test the redirect flow without following redirects.
# next_path must start with "/" per validate_next_path (otherwise it's discarded).
url = reverse("magic-sign-in")
next_path = "workspaces"
next_path = "/workspaces"
response = django_client.post(
url,
{"email": "user@plane.so", "code": token, "next_path": next_path},
@@ -315,8 +319,8 @@ class TestMagicSignIn:
assert response.status_code == 302
assert "error_code" not in response.url
# Check that the redirect URL contains the next_path
assert next_path in response.url
# Check that the redirect URL contains the next_path (URL-encoded, leading slash → %2F)
assert "workspaces" in response.url
# The user should now be authenticated
assert "_auth_user_id" in django_client.session
@@ -427,3 +431,198 @@ class TestMagicSignUp:
# Check if user is authenticated
assert "_auth_user_id" in django_client.session
def _generate_magic_token(api_client, email):
"""Hit /magic-generate/ for `email` and return the token that landed in Redis."""
gen_url = reverse("magic-generate")
response = api_client.post(gen_url, {"email": email}, format="json")
assert response.status_code == status.HTTP_200_OK
ri = redis_instance()
return json.loads(ri.get(f"magic_{email}"))["token"]
@pytest.mark.contract
class TestMagicSignInVerifyAttempts:
"""Per-token wrong-code attempt counter and exhaustion behavior (GHSA-9pvm-fcf6-9234)."""
EMAIL = "verify-attempts@plane.so"
@pytest.fixture
def setup_user(self, db):
user = User.objects.create(email=self.EMAIL)
user.set_password("user@123")
user.save()
return user
@pytest.fixture(autouse=True)
def _clear_state(self):
"""Reset throttle cache and magic-link redis state between tests in this class."""
cache.clear()
ri = redis_instance()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
yield
cache.clear()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_exhausted_after_max_wrong_attempts(
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
):
"""
After MAX_VERIFY_ATTEMPTS wrong codes the next verify must redirect with
EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN and both Redis keys must be gone.
With MAX_VERIFY_ATTEMPTS=5 the 5th wrong attempt itself triggers exhaustion
(4 INVALID + 1 EXHAUSTED), matching the >= check in set_user_data.
"""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-in")
ri = redis_instance()
# First (MAX-1) wrong attempts: each redirects with INVALID_MAGIC_CODE_SIGN_IN.
for i in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1):
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert response.status_code == 302, f"attempt {i+1} unexpected status"
assert "INVALID_MAGIC_CODE_SIGN_IN" in response.url, f"attempt {i+1} did not return INVALID"
# Token and counter both still live, with counter at MAX-1.
assert ri.exists(f"magic_{self.EMAIL}")
assert int(ri.get(f"magic_{self.EMAIL}:verify_attempts")) == MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1
# The MAX-th wrong attempt is the exhausting one.
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert response.status_code == 302
assert "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN" in response.url
# Both the token and the counter must be deleted.
assert not ri.exists(f"magic_{self.EMAIL}")
assert not ri.exists(f"magic_{self.EMAIL}:verify_attempts")
# Follow-up verify now sees the key as missing and reports EXPIRED.
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert response.status_code == 302
assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_counter_increments_on_each_wrong_attempt(
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
):
"""The verify_attempts counter increments by exactly one per wrong-code POST."""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-in")
ri = redis_instance()
counter_key = f"magic_{self.EMAIL}:verify_attempts"
# Before any wrong attempt the counter does not exist (Lua INCR creates it).
assert not ri.exists(counter_key)
for expected in range(1, MagicCodeProvider.MAX_VERIFY_ATTEMPTS):
django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert int(ri.get(counter_key)) == expected, f"counter mismatch after {expected} attempts"
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_counter_resets_on_token_regeneration(
self, mock_magic_link, django_client, api_client, setup_user, setup_instance
):
"""
Regenerating the magic-link must reset the verify-attempt counter so the
user isn't pre-locked-out by a previous session's wrong attempts.
"""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-in")
ri = redis_instance()
counter_key = f"magic_{self.EMAIL}:verify_attempts"
for _ in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 2):
django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert int(ri.get(counter_key)) == MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 2
# Regenerate the magic-link — the counter should be cleared.
_generate_magic_token(api_client, self.EMAIL)
assert not ri.exists(counter_key)
# Fresh wrong attempt now produces INVALID (not EXHAUSTED) and counter starts at 1.
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert "INVALID_MAGIC_CODE_SIGN_IN" in response.url
assert int(ri.get(counter_key)) == 1
@pytest.mark.contract
class TestMagicSignUpVerifyAttempts:
"""Sign-up flow gets the same per-token attempt cap (no existing User row)."""
EMAIL = "signup-verify-attempts@plane.so"
@pytest.fixture(autouse=True)
def _clear_state(self):
cache.clear()
ri = redis_instance()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
yield
cache.clear()
ri.delete(f"magic_{self.EMAIL}")
ri.delete(f"magic_{self.EMAIL}:verify_attempts")
@pytest.mark.django_db
@patch("plane.bgtasks.magic_link_code_task.magic_link.delay")
def test_signup_exhausted_after_max_wrong_attempts(
self, mock_magic_link, django_client, api_client, setup_instance
):
"""The MAX-th wrong code on the sign-up endpoint returns the SIGN_UP variant of EXHAUSTED."""
_generate_magic_token(api_client, self.EMAIL)
url = reverse("magic-sign-up")
ri = redis_instance()
for _ in range(MagicCodeProvider.MAX_VERIFY_ATTEMPTS - 1):
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert "INVALID_MAGIC_CODE_SIGN_UP" in response.url
response = django_client.post(url, {"email": self.EMAIL, "code": "000000"}, follow=False)
assert "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP" in response.url
assert not ri.exists(f"magic_{self.EMAIL}")
assert not ri.exists(f"magic_{self.EMAIL}:verify_attempts")
@pytest.mark.contract
class TestAuthenticationThrottle:
"""Per-IP throttle on the redirect-flow magic-link endpoints."""
@pytest.fixture(autouse=True)
def _clear_state(self):
cache.clear()
yield
cache.clear()
@pytest.mark.django_db
def test_magic_sign_in_throttled(self, django_client, setup_instance):
"""Posting past the configured rate from one IP returns RATE_LIMIT_EXCEEDED."""
url = reverse("magic-sign-in")
# Drop the rate so the test doesn't have to fire 10+ requests.
with patch.object(AuthenticationThrottle, "rate", "2/minute"):
for _ in range(2):
response = django_client.post(url, {"email": "throttle@plane.so", "code": "000000"}, follow=False)
assert response.status_code == 302
assert "RATE_LIMIT_EXCEEDED" not in response.url
# The 3rd request from the same IP within the window trips the throttle.
response = django_client.post(url, {"email": "throttle@plane.so", "code": "000000"}, follow=False)
assert response.status_code == 302
assert "RATE_LIMIT_EXCEEDED" in response.url
@pytest.mark.django_db
def test_magic_sign_up_throttled(self, django_client, setup_instance):
"""The sign-up sibling shares the same scope and trips on the same per-IP budget."""
url = reverse("magic-sign-up")
with patch.object(AuthenticationThrottle, "rate", "1/minute"):
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" not in response.url
response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False)
assert "RATE_LIMIT_EXCEEDED" in response.url
@@ -0,0 +1,143 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for the log cleanup tasks.
Verifies that API activity logs past the retention window are hard-deleted
(removed from PostgreSQL, not soft-deleted) and that fresh logs are retained.
"""
from datetime import timedelta
import pytest
from django.conf import settings
from django.utils import timezone
from uuid import uuid4
from plane.bgtasks.cleanup_task import (
delete_api_logs,
delete_email_notification_logs,
delete_webhook_logs,
process_cleanup_task,
)
from plane.db.models import APIActivityLog, EmailNotificationLog, WebhookLog
from plane.tests.factories import UserFactory, WorkspaceFactory
def _make_api_log(created_at):
log = APIActivityLog.objects.create(
token_identifier="hashed-token",
path="/api/v1/workspaces/",
method="GET",
response_code=200,
)
# created_at is auto-set on insert, so backdate it explicitly afterwards.
APIActivityLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
return log
def _make_webhook_log(workspace, created_at):
log = WebhookLog.objects.create(
workspace=workspace,
webhook=uuid4(),
event_type="issue",
request_method="POST",
response_status="200",
)
WebhookLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
return log
def _make_email_log(user, sent_at):
return EmailNotificationLog.objects.create(
receiver=user,
triggered_by=user,
entity_name="issue",
entity="issue",
sent_at=sent_at,
)
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteApiLogs:
def test_expired_logs_are_hard_deleted(self):
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
expired = _make_api_log(timezone.now() - timedelta(days=retention_days + 1))
delete_api_logs()
# Hard delete: the row must be gone even from the unfiltered manager.
assert not APIActivityLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
recent = _make_api_log(timezone.now() - timedelta(days=retention_days - 1))
delete_api_logs()
assert APIActivityLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteWebhookLogs:
def test_expired_logs_are_hard_deleted(self):
workspace = WorkspaceFactory()
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
expired = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days + 1))
delete_webhook_logs()
assert not WebhookLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
workspace = WorkspaceFactory()
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
recent = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days - 1))
delete_webhook_logs()
assert WebhookLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
@pytest.mark.django_db
class TestDeleteEmailLogs:
def test_expired_logs_are_hard_deleted(self):
user = UserFactory()
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
expired = _make_email_log(user, timezone.now() - timedelta(days=retention_days + 1))
delete_email_notification_logs()
assert not EmailNotificationLog.all_objects.filter(pk=expired.pk).exists()
def test_recent_logs_are_retained(self):
user = UserFactory()
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
recent = _make_email_log(user, timezone.now() - timedelta(days=retention_days - 1))
delete_email_notification_logs()
assert EmailNotificationLog.all_objects.filter(pk=recent.pk).exists()
@pytest.mark.unit
class TestProcessCleanupTaskErrorHandling:
def test_batch_delete_failure_is_swallowed(self):
"""A failing batch is logged and skipped; the run does not raise."""
class _BoomManager:
@staticmethod
def filter(**kwargs):
raise RuntimeError("db unavailable")
class _BoomModel:
all_objects = _BoomManager()
# Should not raise even though the delete blows up.
process_cleanup_task(lambda: iter([1, 2, 3]), _BoomModel, "Boom")
@@ -78,6 +78,7 @@ class TestCopyS3Objects:
mock_sync.return_value = {
"description": "test description",
"description_binary": base64.b64encode(b"test binary").decode(),
"description_json": {"type": "doc", "content": []},
}
# Call the actual function (not .delay())
@@ -0,0 +1,289 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Per-advisory SSRF regression tests.
Each test reproduces a published / reported SSRF advisory scenario and asserts
the current code blocks it. This file is the auditable map of "which advisory is
covered where"; the lower-level mechanics (IP classification, pinning, redirect
re-validation) are exercised in detail in ``test_url_security.py`` and
``test_work_item_link_task.py``.
Advisory coverage
-----------------
Webhook delivery
* GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx
webhook URL resolves to a private/metadata/loopback IP -> TestWebhookUrlValidation
* GHSA-75fg-f8qg-23wv CGNAT(100.64/10), 6to4, multicast missed -> TestWebhookUrlValidation
* GHSA-6485-m23r-fx8q PATCH serializer context-key bypass -> TestWebhookPatchContextGuard
* GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 /
-9292-pvg4-7hvm / -fgcv-6h3f-xcx9 webhook DNS-rebinding TOCTOU -> TestWebhookRebinding
* GHSA-6v37-328w-j2wv / -jw6g-h7h5-rfc6 / -mq87-52pf-hm3h
webhook SSRF via HTTP redirect following -> TestWebhookRedirect
Work-item link unfurling / favicon
* GHSA-8wvv-p676-hcw4 / -fv24-3845-646g / -9292-pvg4-7hvm link rebinding
* GHSA-9fr2-pprw-pp9j / CVE-2026-39843 favicon redirect SSRF -> TestFaviconRedirect
* GHSA-3856-6mgg-rx84 favicon DNS-rebinding -> TestFaviconRebinding
OAuth avatar (the still-unresolved family this change adds)
* GHSA-cv9p-325g-wmv5 OAuth avatar redirect SSRF -> static-asset exfil
* GHSA-hx79-5pj5-qh42 Gitea OAuth SSRF (avatar hop) -> TestOAuthAvatarSSRF
"""
import pytest
import requests
from unittest.mock import MagicMock, patch
from bs4 import BeautifulSoup
from plane.utils.ip_address import validate_url
from plane.bgtasks.work_item_link_task import fetch_and_encode_favicon, DEFAULT_FAVICON
from plane.authentication.adapter.base import Adapter
def _addr(ip):
family = 6 if ":" in ip else 2
return (family, None, None, None, (ip, 0))
def _resp(status_code=200, headers=None, content=b"OK"):
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.content = content
return resp
_BLOCKED = "Access to private/internal networks is not allowed"
# ---------------------------------------------------------------------------
# Webhook URL validation (creation/update-time defense in depth)
# GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx / GHSA-75fg-f8qg-23wv
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookUrlValidation:
@pytest.mark.parametrize(
"ip",
[
"169.254.169.254", # AWS/GCP metadata (CVE-2026-30242 PoC)
"127.0.0.1", # loopback
"10.0.0.1", # private
"172.16.0.1", # private
"192.168.0.1", # private
"::1", # IPv6 loopback
"100.64.0.1", # CGNAT / RFC 6598 (GHSA-75fg)
"2002:7f00:1::", # 6to4 -> 127.0.0.1 (GHSA-75fg)
"224.0.0.1", # multicast (GHSA-75fg)
"::ffff:169.254.169.254", # IPv4-mapped metadata
],
)
def test_webhook_url_to_internal_is_rejected(self, ip):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr(ip)]
with pytest.raises(ValueError, match="private/internal"):
validate_url(
"https://attacker.example.com/hook",
allowed_ips=[],
allowed_hosts=[],
)
def test_legitimate_public_webhook_url_passes(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
# Should not raise
validate_url("https://hooks.example.com/x", allowed_ips=[], allowed_hosts=[])
# ---------------------------------------------------------------------------
# GHSA-6485-m23r-fx8q — PATCH serializer context-key bypass
# The PATCH view now passes context={"request": request}; with the request in
# context the disallowed-domain / request-host loop-back guard runs on update.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookPatchContextGuard:
def _serializer_with_request(self, host):
from plane.app.serializers import WebhookSerializer
request = MagicMock()
request.get_host.return_value = host
return WebhookSerializer(context={"request": request})
def test_request_host_is_blocked_when_context_present(self):
# A webhook pointed at the instance's own host must be rejected — this
# is the guard the PATCH endpoint silently skipped with the wrong key.
ser = self._serializer_with_request("myplane.example.com")
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")] # public, so only the host guard can block
with pytest.raises(Exception, match="not allowed"):
ser._validate_webhook_url("https://myplane.example.com/hook")
def test_unrelated_public_host_passes_with_context(self):
ser = self._serializer_with_request("myplane.example.com")
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
ser._validate_webhook_url("https://hooks.partner.com/x") # should not raise
# ---------------------------------------------------------------------------
# Webhook DNS-rebinding TOCTOU
# GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 / -9292 / -fgcv
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookRebinding:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_connection_pinned_to_validated_ip(self, mock_resolve, mock_session_cls):
from plane.utils.url_security import pinned_fetch
# The validator resolves to a public IP; the connection must go to THAT
# IP literal, so a rebind to an internal IP after validation is moot.
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
_, url = session.request.call_args.args
assert url == "https://93.184.216.34:443/hook" # IP literal -> no 2nd DNS lookup
@patch("plane.utils.url_security.resolve_and_validate")
def test_rebind_to_internal_is_blocked(self, mock_resolve):
from plane.utils.url_security import pinned_fetch
mock_resolve.side_effect = ValueError(_BLOCKED)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
# ---------------------------------------------------------------------------
# Webhook SSRF via HTTP redirect following
# GHSA-6v37-328w-j2wv / GHSA-jw6g-h7h5-rfc6 / GHSA-mq87-52pf-hm3h
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookRedirect:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_webhook_does_not_follow_redirects(self, mock_resolve, mock_session_cls):
from plane.utils.url_security import pinned_fetch
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
# The endpoint replies 302 -> internal; the webhook client must NOT follow.
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
)
resp = pinned_fetch("POST", "https://hooks.example.com/x", json={})
# The 3xx is returned as-is and only ONE request was made (no follow).
assert resp.status_code == 302
assert session.request.call_count == 1
assert session.request.call_args.kwargs["allow_redirects"] is False
# ---------------------------------------------------------------------------
# Favicon redirect SSRF — GHSA-9fr2-pprw-pp9j / CVE-2026-39843
# A <link rel=icon> whose href is public but 30x-redirects to a private IP must
# NOT exfiltrate internal content; the favicon falls back to the default icon.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFaviconRedirect:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
def test_favicon_redirect_to_private_returns_default(
self, mock_pre_dns, mock_resolve, mock_session_cls
):
# validate_url_ip pre-check (work_item_link_task.socket) sees a public IP.
mock_pre_dns.return_value = [_addr("93.184.216.34")]
# safe_get: hop0 public, hop1 (redirect target) blocked.
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://192.168.8.14:8081/"}
)
soup = BeautifulSoup(
'<link rel="icon" href="https://redirector.example.com/x">',
"html.parser",
)
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
# Blocked -> default icon, NOT the internal response body.
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
# ---------------------------------------------------------------------------
# Favicon DNS rebinding — GHSA-3856-6mgg-rx84
# The favicon host passes the pre-check (public) but resolves to a private IP at
# fetch time; the pinned client re-resolves+validates and blocks it.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFaviconRebinding:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
def test_favicon_rebind_to_private_returns_default(
self, mock_pre_dns, mock_resolve, mock_session_cls
):
mock_pre_dns.return_value = [_addr("93.184.216.34")] # pre-check: public
mock_resolve.side_effect = ValueError(_BLOCKED) # fetch-time: rebound -> blocked
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
soup = BeautifulSoup(
'<link rel="icon" href="http://rebind.example.com:8443/">',
"html.parser",
)
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
# ---------------------------------------------------------------------------
# OAuth avatar SSRF — GHSA-cv9p-325g-wmv5 / GHSA-hx79-5pj5-qh42 (avatar hop)
# download_and_upload_avatar must reject avatar URLs that point at, or redirect
# to, internal addresses, returning None (no fetch stored as an asset).
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOAuthAvatarSSRF:
def _adapter(self):
return Adapter(request=MagicMock(), provider="gitea")
@patch("plane.utils.url_security.resolve_and_validate")
def test_avatar_to_internal_ip_is_blocked(self, mock_resolve):
mock_resolve.side_effect = ValueError(_BLOCKED)
result = self._adapter().download_and_upload_avatar(
"http://169.254.169.254/latest/meta-data/", user=MagicMock()
)
assert result is None
mock_resolve.assert_called() # SSRF validation was actually attempted
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_avatar_redirect_to_internal_is_blocked(self, mock_resolve, mock_session_cls):
# Public avatar URL that 302-redirects to the metadata service.
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/imds"}
)
result = self._adapter().download_and_upload_avatar(
"https://evil.example.com/avatar", user=MagicMock()
)
assert result is None
@patch("plane.authentication.adapter.base.pinned_fetch_following_redirects")
def test_avatar_uses_ssrf_safe_client(self, mock_fetch):
# Wiring guard: the avatar path must go through the pinned client, never
# a raw requests.get (which would re-resolve + follow redirects freely).
mock_fetch.side_effect = ValueError(_BLOCKED)
result = self._adapter().download_and_upload_avatar(
"https://cdn.example.com/a.png", user=MagicMock()
)
assert result is None
assert mock_fetch.call_args.args[0] == "GET"
assert mock_fetch.call_args.args[1] == "https://cdn.example.com/a.png"
@@ -0,0 +1,395 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
SSRF-protection tests for the webhook + link-unfurling clusters (advisories A/B/C):
A — incomplete private-IP validation -> is_blocked_ip hardening
B — DNS-rebinding TOCTOU -> connection pinned to the validated IP
C — SSRF via HTTP redirect following -> redirects re-resolved/re-validated/re-pinned
"""
import ipaddress
import pytest
import requests
from unittest.mock import MagicMock, patch
from plane.utils.ip_address import is_blocked_ip, resolve_and_validate, validate_url
from plane.utils.url_security import (
PinnedIPAdapter,
pinned_fetch,
pinned_fetch_following_redirects,
)
def _addr(ip):
"""Build a single getaddrinfo-style result tuple for an IP string."""
family = 6 if ":" in ip else 2
return (family, None, None, None, (ip, 0))
def _resp(status_code=200, headers=None, content=b"OK"):
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.content = content
return resp
# ---------------------------------------------------------------------------
# Cluster A — robust IP classification (verified on Python 3.12 semantics)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestIsBlockedIp:
@pytest.mark.parametrize(
"ip",
[
"127.0.0.1", # loopback
"10.0.0.1", # private
"192.168.1.1", # private
"172.16.0.1", # private
"169.254.169.254", # link-local / cloud metadata
"0.0.0.0", # unspecified
"100.64.0.1", # CGNAT / shared (NOT is_private on py3.12!)
"224.0.0.1", # multicast
"239.255.255.250", # SSDP multicast
"255.255.255.255", # limited broadcast
"::1", # IPv6 loopback
"fe80::1", # IPv6 link-local
"fc00::1", # IPv6 unique-local
"ff02::1", # IPv6 multicast
"::ffff:127.0.0.1", # IPv4-mapped loopback
"::ffff:169.254.169.254", # IPv4-mapped metadata
"::ffff:10.0.0.1", # IPv4-mapped private
"64:ff9b::7f00:1", # NAT64 well-known prefix embedding 127.0.0.1
"64:ff9b::a9fe:a9fe", # NAT64 well-known prefix embedding 169.254.169.254
"64:ff9b:1::7f00:1", # NAT64 local-use prefix (RFC 8215, /48)
"64:ff9b:1:0100::1", # NAT64 local-use prefix, outside the /96 subset
"2002:7f00:1::", # 6to4 embedding 127.0.0.1
"2002:a00:1::", # 6to4 embedding 10.0.0.1
],
)
def test_blocks_internal(self, ip):
assert is_blocked_ip(ipaddress.ip_address(ip)) is True
@pytest.mark.parametrize(
"ip",
[
"8.8.8.8",
"93.184.216.34",
"1.1.1.1",
"2606:4700:4700::1111", # public IPv6 (Cloudflare)
"2001:4860:4860::8888", # public IPv6 (Google)
],
)
def test_allows_public(self, ip):
assert is_blocked_ip(ipaddress.ip_address(ip)) is False
# ---------------------------------------------------------------------------
# resolve_and_validate — resolution + validation, returns IPs to pin
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestResolveAndValidate:
def test_returns_public_ips(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34")]
assert resolve_and_validate("example.com") == ["93.184.216.34"]
def test_raises_on_private(self):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("10.0.0.1")]
with pytest.raises(ValueError, match="private/internal"):
resolve_and_validate("internal.example.com")
def test_raises_if_any_resolved_ip_is_private(self):
# A hostname that resolves to BOTH a public and a private IP must fail
# closed — an attacker could otherwise steer the connection to the
# private one.
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("93.184.216.34"), _addr("127.0.0.1")]
with pytest.raises(ValueError, match="private/internal"):
resolve_and_validate("rebinder.example.com")
def test_allowlist_permits_private(self):
allowed = [ipaddress.ip_network("10.0.0.0/8")]
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr("10.0.0.5")]
assert resolve_and_validate("internal", allowed_ips=allowed) == ["10.0.0.5"]
def test_unresolvable_raises(self):
import socket as _socket
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.side_effect = _socket.gaierror()
with pytest.raises(ValueError, match="could not be resolved"):
resolve_and_validate("nope.invalid")
# ---------------------------------------------------------------------------
# Cluster B — connection pinned to the validated IP (DNS-rebinding TOCTOU)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedFetch:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_connects_to_validated_ip_not_hostname(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("POST", "https://example.com/hook", json={"a": 1})
# The socket target is the validated IP literal — there is no second
# DNS lookup, so a rebind between validation and connection is
# impossible.
method, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert method == "POST"
assert url == "https://93.184.216.34:443/hook"
# Host header + TLS SNI still target the real hostname.
assert kwargs["headers"]["Host"] == "example.com"
assert kwargs["allow_redirects"] is False
assert kwargs["verify"] is True
assert kwargs["json"] == {"a": 1}
# Ambient proxy/env must not be honoured (would bypass pinning).
assert session.trust_env is False
assert kwargs["proxies"] == {"http": None, "https": None}
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_non_default_port_in_host_header(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "http://example.com:8080/x")
_, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert url == "http://93.184.216.34:8080/x"
assert kwargs["headers"]["Host"] == "example.com:8080"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_ipv6_validated_ip_is_bracketed(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["2606:4700:4700::1111"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://example.com/x")
_, url = session.request.call_args.args
assert url == "https://[2606:4700:4700::1111]:443/x"
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocked_target_raises_before_any_request(self, mock_resolve):
mock_resolve.side_effect = ValueError(
"Access to private/internal networks is not allowed"
)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch("POST", "https://attacker.com/hook")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_tries_next_ip_on_connection_error(self, mock_resolve, mock_session_cls):
# Dual-stack host: first validated IP is unreachable, second works.
mock_resolve.return_value = ["93.184.216.34", "93.184.216.35"]
session = mock_session_cls.return_value
session.request.side_effect = [
requests.ConnectionError("down"),
_resp(200),
]
resp = pinned_fetch("GET", "https://example.com/x")
assert resp.status_code == 200
assert session.request.call_count == 2
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_allowed_host_skips_block_check_but_still_pins(self, mock_resolve, mock_session_cls):
# Trusted host (e.g. internal docker service) whose IP is private: the
# block check is skipped, but the connection is STILL pinned to the
# resolved IP so it cannot be rebound to a different internal target.
mock_resolve.return_value = ["172.18.0.5"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch(
"POST",
"http://silo:3000/hook",
allowed_hosts=["silo"],
json={"x": 1},
)
# Resolution happens with require_safe=False (trusted, skip block check).
assert mock_resolve.call_args.kwargs.get("require_safe") is False
# ...but the connection is pinned to the resolved IP literal, Host=silo.
_, url = session.request.call_args.args
assert url == "http://172.18.0.5:3000/hook"
assert session.request.call_args.kwargs["headers"]["Host"] == "silo:3000"
assert session.request.call_args.kwargs["allow_redirects"] is False
# ---------------------------------------------------------------------------
# Cluster C — redirects re-resolved / re-validated / re-pinned each hop
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedFetchRedirects:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_no_redirect_returns_response(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
assert resp.status_code == 200
assert final == "https://example.com/a"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_follows_and_revalidates_each_hop(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_resp(301, headers={"Location": "https://other.com/page"}),
_resp(200),
]
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
assert resp.status_code == 200
assert final == "https://other.com/page"
# Re-resolved (and thus re-validated + re-pinned) on each hop.
assert mock_resolve.call_count == 2
assert mock_resolve.call_args_list[0].args[0] == "example.com"
assert mock_resolve.call_args_list[1].args[0] == "other.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
# First hop resolves public; redirect target resolves private -> blocked
mock_resolve.side_effect = [
["93.184.216.34"],
ValueError("Access to private/internal networks is not allowed"),
]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
)
with pytest.raises(ValueError, match="private/internal"):
pinned_fetch_following_redirects("GET", "https://evil.com/r")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_too_many_redirects(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(
302, headers={"Location": "https://example.com/loop"}
)
with pytest.raises(requests.TooManyRedirects):
pinned_fetch_following_redirects(
"GET", "https://example.com/start", max_redirects=3
)
# ---------------------------------------------------------------------------
# PinnedIPAdapter — TLS server_hostname injection (cert verified vs hostname)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPinnedIPAdapter:
def test_injects_server_hostname_into_pool(self):
adapter = PinnedIPAdapter(server_hostname="example.com")
adapter.build_connection_pool_key_attributes = MagicMock(
return_value=({"scheme": "https", "host": "93.184.216.34", "port": 443}, {})
)
adapter.poolmanager = MagicMock()
request = MagicMock()
adapter.get_connection_with_tls_context(request, verify=True)
_, kwargs = adapter.poolmanager.connection_from_host.call_args
assert kwargs["pool_kwargs"]["server_hostname"] == "example.com"
# ---------------------------------------------------------------------------
# validate_url — create/update-time defense in depth still rejects bypasses
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestValidateUrlHardening:
@pytest.mark.parametrize("ip", ["100.64.0.1", "224.0.0.1", "0.0.0.0"])
def test_rejects_newly_covered_ranges(self, ip):
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.return_value = [_addr(ip)]
with pytest.raises(ValueError, match="private/internal"):
validate_url("http://attacker.example.com")
# ---------------------------------------------------------------------------
# Review-feedback fixes (PR #9163)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestReviewFixes:
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_url_embedded_credentials_become_basic_auth(self, mock_resolve, mock_session_cls):
# user:pass@host -> Basic Auth preserved as auth=, userinfo stripped from URL
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://user:p%40ss@example.com/hook")
_, url = session.request.call_args.args
kwargs = session.request.call_args.kwargs
assert url == "https://93.184.216.34:443/hook" # no userinfo in the IP URL
assert kwargs["auth"] == ("user", "p@ss") # percent-decoded
assert kwargs["headers"]["Host"] == "example.com"
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_no_credentials_passes_auth_none(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://example.com/x")
assert session.request.call_args.kwargs["auth"] is None
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_ipv6_literal_host_header_is_bracketed(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["2606:4700:4700::1111"]
session = mock_session_cls.return_value
session.request.return_value = _resp(200)
pinned_fetch("GET", "https://[2606:4700:4700::1111]/x")
kwargs = session.request.call_args.kwargs
assert kwargs["headers"]["Host"] == "[2606:4700:4700::1111]"
def test_idna_unicode_error_is_treated_as_unresolvable(self):
# getaddrinfo can raise UnicodeError (IDNA) before any lookup; it must
# surface as ValueError so webhook_send_task records a URL rejection.
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
dns.side_effect = UnicodeError("label empty or too long")
with pytest.raises(ValueError, match="could not be resolved"):
resolve_and_validate("xn--bad-name")
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_stream_defers_session_close_until_response_close(self, mock_resolve, mock_session_cls):
# With stream=True the size cap can bound memory only if the session
# stays open until the body is read; closing the response closes it.
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
resp = _resp(200)
session.request.return_value = resp
out = pinned_fetch("GET", "https://cdn.example.com/a.png", stream=True)
assert session.request.call_args.kwargs["stream"] is True
session.close.assert_not_called() # deferred
out.close()
session.close.assert_called_once()
@@ -5,6 +5,7 @@
import ipaddress
import pytest
import requests
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
@@ -45,6 +46,22 @@ class TestValidateUrlIp:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
validate_url_ip("https://example.com") # Should not raise
@pytest.mark.parametrize(
"ip",
[
"100.64.0.1", # CGNAT / shared address space (not is_private on 3.12)
"224.0.0.1", # multicast
"0.0.0.0", # unspecified
"::ffff:169.254.169.254", # IPv4-mapped cloud metadata
"64:ff9b::a9fe:a9fe", # NAT64 embedding 169.254.169.254
],
)
def test_rejects_hardened_bypass_ranges(self, ip):
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
with pytest.raises(ValueError, match="private/internal"):
validate_url_ip("http://attacker.example.com")
@pytest.mark.unit
class TestValidateUrlAllowlist:
@@ -133,82 +150,81 @@ class TestValidateUrlAllowlist:
@pytest.mark.unit
class TestSafeGet:
"""Test safe_get follows redirects safely and blocks SSRF."""
"""safe_get now delegates to the pinned SSRF-safe client; assert it resolves,
validates, pins to the validated IP and follows redirects safely. Network is
mocked at the requests.Session boundary inside plane.utils.url_security."""
@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
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_returns_response_for_non_redirect(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _make_response(status_code=200, content=b"OK")
response, final_url = safe_get("https://example.com")
assert response is final_resp
assert response.status_code == 200
assert final_url == "https://example.com"
mock_validate.assert_called_once_with("https://example.com")
# Pinned to the validated IP literal, not the hostname.
_, url = session.request.call_args.args
assert url == "https://93.184.216.34:443/"
assert session.request.call_args.kwargs["headers"]["Host"] == "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]
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_follows_redirect_and_validates_each_hop(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_make_response(status_code=301, headers={"Location": "https://other.com/page"}),
_make_response(status_code=200, content=b"OK"),
]
response, final_url = safe_get("https://example.com")
assert response is final_resp
assert response.status_code == 200
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")
assert mock_resolve.call_count == 2
assert mock_resolve.call_args_list[0].args[0] == "example.com"
assert mock_resolve.call_args_list[1].args[0] == "other.com"
@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"},
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
mock_resolve.side_effect = [
["93.184.216.34"],
ValueError("Access to private/internal networks is not allowed"),
]
session = mock_session_cls.return_value
session.request.return_value = _make_response(
status_code=302, 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"},
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_raises_on_too_many_redirects(self, mock_resolve, mock_session_cls):
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.return_value = _make_response(
status_code=302, headers={"Location": "https://example.com/loop"}
)
mock_get.return_value = redirect_resp
with pytest.raises(RuntimeError, match="Too many redirects"):
with pytest.raises(requests.TooManyRedirects):
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]
@patch("plane.utils.url_security.requests.Session")
@patch("plane.utils.url_security.resolve_and_validate")
def test_succeeds_at_exact_max_redirects(self, mock_resolve, mock_session_cls):
"""5 redirects then a 200 must succeed (MAX_REDIRECTS == 5)."""
mock_resolve.return_value = ["93.184.216.34"]
session = mock_session_cls.return_value
session.request.side_effect = [
_make_response(status_code=302, headers={"Location": "https://example.com/next"})
] * 5 + [_make_response(status_code=200, content=b"OK")]
response, final_url = safe_get("https://example.com/start")
assert response is final_resp
assert not response.is_redirect
assert response.status_code == 200
@@ -0,0 +1,79 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Unit tests for APITokenLogMiddleware.
Covers the credential-hygiene guarantees of the external API request logger:
- the raw API key is never persisted (a non-reversible hash is stored instead)
- sensitive request headers are redacted before being logged
"""
import hashlib
import hmac
from unittest.mock import Mock, patch
import pytest
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse
from django.test import RequestFactory
from plane.middleware.logger import APITokenLogMiddleware
@pytest.fixture
def request_factory():
return RequestFactory()
@pytest.fixture
def middleware():
return APITokenLogMiddleware(Mock(return_value=HttpResponse(b"{}")))
@pytest.mark.unit
class TestAPITokenLogMiddleware:
API_KEY = "plane_api_supersecretvalue"
AUTHORIZATION = "Bearer secret-bearer-token"
COOKIE = "sessionid=secret-session-value"
def _captured_log_data(self, middleware, request_factory):
request = request_factory.get(
"/api/v1/workspaces/",
HTTP_X_API_KEY=self.API_KEY,
HTTP_AUTHORIZATION=self.AUTHORIZATION,
HTTP_COOKIE=self.COOKIE,
)
request.user = AnonymousUser()
response = HttpResponse(b"{}")
with patch("plane.middleware.logger.process_logs") as process_logs:
middleware.process_request(request, response, request_body=b"")
assert process_logs.delay.called
return process_logs.delay.call_args.kwargs["log_data"]
def test_token_identifier_is_hashed_not_plaintext(self, middleware, request_factory):
log_data = self._captured_log_data(middleware, request_factory)
expected_hash = hmac.new(
settings.SECRET_KEY.encode(), self.API_KEY.encode(), hashlib.sha256
).hexdigest()
assert log_data["token_identifier"] == expected_hash
assert self.API_KEY not in log_data["token_identifier"]
def test_sensitive_headers_are_redacted(self, middleware, request_factory):
log_data = self._captured_log_data(middleware, request_factory)
# None of the sensitive header values may appear in the logged headers.
assert self.API_KEY not in log_data["headers"]
assert self.AUTHORIZATION not in log_data["headers"]
assert self.COOKIE not in log_data["headers"]
assert "[REDACTED]" in log_data["headers"]
def test_no_log_without_api_key(self, middleware, request_factory):
request = request_factory.get("/api/v1/workspaces/")
request.user = AnonymousUser()
with patch("plane.middleware.logger.process_logs") as process_logs:
middleware.process_request(request, HttpResponse(b"{}"), request_body=b"")
assert not process_logs.delay.called
@@ -0,0 +1,34 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Unit tests for the log-retention env parsing helper."""
import pytest
from plane.settings.common import _retention_days
ENV_VAR = "TEST_RETENTION_DAYS"
@pytest.mark.unit
class TestRetentionDays:
def test_uses_default_when_unset(self, monkeypatch):
monkeypatch.delenv(ENV_VAR, raising=False)
assert _retention_days(ENV_VAR, 14) == 14
def test_uses_env_value_when_valid(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "30")
assert _retention_days(ENV_VAR, 14) == 30
def test_zero_is_allowed(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "0")
assert _retention_days(ENV_VAR, 14) == 0
def test_negative_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "-5")
assert _retention_days(ENV_VAR, 14) == 14
def test_unparseable_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "abc")
assert _retention_days(ENV_VAR, 7) == 7
+20 -12
View File
@@ -68,16 +68,20 @@ class TestContainsURL:
assert contains_url("www.") is False # Incomplete www - needs at least one char after dot
def test_contains_url_length_limit_under_1000(self):
"""Test contains_url with input under 1000 characters containing URLs"""
# Create a string under 1000 characters with a URL
text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars
"""Test contains_url with input under 1000 characters containing URLs.
Note: contains_url also truncates each line to 500 chars (ReDoS protection),
so URLs must fall within the first 500 chars of their line.
"""
# Single line under 500 chars with URL at the end
text_with_url = "a" * 470 + " https://example.com" # 490 chars total
assert len(text_with_url) < 1000
assert contains_url(text_with_url) is True
# Test with exactly 1000 characters
text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
assert len(text_exact_1000) == 1000
assert contains_url(text_exact_1000) is True
# Multi-line input under 1000 chars total; URL on its own short line
text_multiline = "a" * 480 + "\nhttps://example.com\n" + "b" * 480
assert len(text_multiline) < 1000
assert contains_url(text_multiline) is True
def test_contains_url_length_limit_over_1000(self):
"""Test contains_url with input over 1000 characters returns False"""
@@ -91,14 +95,17 @@ class TestContainsURL:
assert contains_url(long_text_with_url) is False
def test_contains_url_length_limit_exactly_1000(self):
"""Test contains_url with input exactly 1000 characters"""
"""Test contains_url with input exactly 1000 characters.
URLs must fall within the first 500 chars of their line (ReDoS protection).
"""
# Test with exactly 1000 characters without URL
text_no_url = "a" * 1000
assert len(text_no_url) == 1000
assert contains_url(text_no_url) is False
# Test with exactly 1000 characters with URL at the end
text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars
# Multi-line totalling exactly 1000 chars; URL on a short line
text_with_url = "a" * 480 + "\nhttps://example.com\n" + "b" * 499 # 480+1+19+1+499 = 1000
assert len(text_with_url) == 1000
assert contains_url(text_with_url) is True
@@ -121,8 +128,9 @@ class TestContainsURL:
over_limit_text = "a" * 1001 # No URL, but over total limit
assert contains_url(over_limit_text) is False
# Test that under total limit, line processing works normally
under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total
# Test that under total limit, line processing works normally.
# URL must be within first 500 chars of its line (ReDoS protection).
under_limit_with_url = "a" * 400 + "https://example.com" # 419 chars total, fits in 500
assert len(under_limit_with_url) < 1000
assert contains_url(under_limit_with_url) is True
@@ -0,0 +1,3 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
@@ -0,0 +1,69 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Regression tests for the ``dispatch()`` exception handling on the shared
``BaseAPIView`` / ``BaseViewSet`` classes.
When ``super().dispatch()`` raises an unhandled exception, ``dispatch()`` must
return the HTTP ``Response`` produced by ``handle_exception()`` -- not the raw
exception object. Returning the exception causes Django's response pipeline to
fail with ``TypeError: 'Exception' object is not a valid HTTP response``.
See: https://github.com/makeplane/plane/issues/9157
"""
import pytest
from unittest.mock import patch
from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from plane.api.views.base import BaseAPIView as ApiBaseAPIView, BaseViewSet as ApiBaseViewSet
from plane.app.views.base import BaseAPIView as AppBaseAPIView, BaseViewSet as AppBaseViewSet
from plane.license.api.views.base import BaseAPIView as LicenseBaseAPIView
from plane.space.views.base import BaseAPIView as SpaceBaseAPIView, BaseViewSet as SpaceBaseViewSet
# Every shared base view that wraps ``super().dispatch()`` in a try/except.
VIEW_CLASSES = [
ApiBaseAPIView,
ApiBaseViewSet,
AppBaseAPIView,
AppBaseViewSet,
LicenseBaseAPIView,
SpaceBaseAPIView,
SpaceBaseViewSet,
]
@pytest.mark.unit
@pytest.mark.parametrize(
"view_class",
VIEW_CLASSES,
ids=lambda c: f"{c.__module__}.{c.__name__}",
)
def test_dispatch_returns_response_when_super_dispatch_raises(view_class):
"""dispatch() must return handle_exception()'s Response, not the exception."""
request = APIRequestFactory().get("/api/test/")
view = view_class()
sentinel = Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
with (
patch("rest_framework.views.APIView.dispatch", side_effect=RuntimeError("boom")),
patch.object(view_class, "handle_exception", return_value=sentinel) as mock_handle,
):
result = view.dispatch(request)
mock_handle.assert_called_once()
assert isinstance(result, Response), (
f"{view_class.__module__}.{view_class.__name__}.dispatch() returned "
f"{type(result).__name__} instead of an HTTP Response"
)
assert result is sentinel
+153 -16
View File
@@ -8,10 +8,162 @@ import socket
from urllib.parse import urlparse
# Networks that must never be reachable as an outbound request target but which
# the stdlib ``ipaddress`` flags (is_private/is_loopback/...) do NOT reliably
# classify on every Python version. Listed explicitly so the verdict is
# identical and fail-closed across Python 3.9 3.14 (Plane ships on 3.12,
# where e.g. 100.64.0.0/10 is neither is_private nor is_global).
_BLOCKED_NETWORKS = [
ipaddress.ip_network(cidr)
for cidr in (
"0.0.0.0/8", # "this host on this network" (RFC 1122) / unspecified block
"100.64.0.0/10", # carrier-grade NAT / shared address space (RFC 6598)
"169.254.0.0/16", # link-local (incl. cloud metadata 169.254.169.254)
"255.255.255.255/32", # limited broadcast
"::ffff:0:0/96", # IPv4-mapped IPv6
"64:ff9b::/96", # NAT64 well-known prefix (RFC 6052)
"64:ff9b:1::/48", # NAT64 local-use prefix (RFC 8215)
"2002::/16", # 6to4
"2001::/32", # Teredo
"fec0::/10", # deprecated IPv6 site-local
)
]
def _embedded_ipv4(ip):
"""
Yield any IPv4 address embedded inside an IPv6 transition address.
An attacker who controls a hostname's AAAA record can point it at an IPv6
address that the network transparently translates to an internal IPv4
target (e.g. ``::ffff:169.254.169.254``, ``64:ff9b::7f00:1`` → 127.0.0.1,
6to4, Teredo). The embedded IPv4 is what the packet ultimately reaches, so
it must be validated too — we cannot trust the interpreter to classify the
outer IPv6 address consistently across versions.
"""
if ip.version != 6:
return
if ip.ipv4_mapped is not None:
yield ip.ipv4_mapped
if ip.sixtofour is not None:
yield ip.sixtofour
teredo = ip.teredo
if teredo is not None:
# (server_ipv4, client_ipv4)
yield teredo[0]
yield teredo[1]
# NAT64 well-known prefix (64:ff9b::/96): the low 32 bits embed the IPv4.
# The local-use prefix 64:ff9b:1::/48 uses a different (length-dependent)
# embedding per RFC 6052, so it is not decoded here — it is blocked wholesale
# via _BLOCKED_NETWORKS instead.
if ip in ipaddress.ip_network("64:ff9b::/96"):
yield ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
def is_blocked_ip(ip):
"""
Return ``True`` if ``ip`` (an ``ipaddress`` address object) should never be
used as an outbound request target (SSRF protection).
Blocks private, loopback, reserved, link-local, multicast and unspecified
ranges; an explicit deny-list of networks the stdlib misclassifies on some
Python versions; and recurses into IPv4 addresses embedded in IPv6
transition formats. Fails closed: anything it cannot positively clear is
treated as blocked.
"""
if (
ip.is_private
or ip.is_loopback
or ip.is_reserved
or ip.is_link_local
or ip.is_multicast
or ip.is_unspecified
):
return True
if any(ip.version == net.version and ip in net for net in _BLOCKED_NETWORKS):
return True
for embedded in _embedded_ipv4(ip):
if is_blocked_ip(embedded):
return True
return False
def _is_allowed_ip(ip, allowed_ips):
"""Return True if ``ip`` falls inside an operator-trusted allowlist network."""
return bool(allowed_ips) and any(
net.version == ip.version and ip in net for net in allowed_ips
)
def resolve_and_validate(hostname, allowed_ips=None, require_safe=True):
"""
Resolve ``hostname`` and (when ``require_safe``) ensure every resolved
address is a safe outbound target, returning the list of resolved IP
strings (in resolver order, de-duplicated).
The returned list is intended to be *pinned* for the actual connection
(connect to the IP literal so no second DNS lookup occurs), which is what
closes the DNS-rebinding TOCTOU.
Args:
hostname: The hostname (or IP literal) to resolve.
allowed_ips: Optional list of ``ipaddress.ip_network`` objects. IPs
inside these networks are permitted even if otherwise
blocked (operator-trusted internal targets).
require_safe: When ``True`` (default) every resolved IP is checked and a
blocked/internal address raises. When ``False`` the host is
already operator-trusted (e.g. a WEBHOOK_ALLOWED_HOSTS
entry) so the block check is skipped — but resolution still
happens so the connection can be pinned (pinning prevents
rebinding even for trusted hosts).
Returns:
list[str]: The resolved IP addresses to which a connection may be
pinned.
Raises:
ValueError: If the hostname cannot be resolved or (when
``require_safe``) any resolved address is a blocked/internal target not
covered by ``allowed_ips``.
"""
try:
addr_info = socket.getaddrinfo(hostname, None)
except (socket.gaierror, UnicodeError):
# UnicodeError covers IDNA encoding/normalisation failures, which
# getaddrinfo raises before the address lookup for malformed hostnames.
raise ValueError("Hostname could not be resolved")
if not addr_info:
raise ValueError("No IP addresses found for the hostname")
validated = []
for addr in addr_info:
# Strip any IPv6 zone id (e.g. ``fe80::1%eth0``) before parsing.
ip_str = addr[4][0].split("%")[0]
ip = ipaddress.ip_address(ip_str)
if require_safe and not _is_allowed_ip(ip, allowed_ips) and is_blocked_ip(ip):
raise ValueError("Access to private/internal networks is not allowed")
if ip_str not in validated:
validated.append(ip_str)
return validated
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).
Note: this validates at a point in time. To defeat DNS-rebinding (TOCTOU),
the actual request must be pinned to the validated IP — see
``plane.utils.url_security.pinned_fetch``.
Args:
url: The URL to validate.
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
@@ -41,22 +193,7 @@ def validate_url(url, allowed_ips=None, allowed_hosts=None):
}:
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")
resolve_and_validate(hostname, allowed_ips=allowed_ips)
def get_client_ip(request):
+59
View File
@@ -0,0 +1,59 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
Shared OTLP endpoint helpers so metrics and traces use the same collector
when both are enabled. One URL (OTLP_ENDPOINT) is enough: same as traces
(e.g. https://telemetry.plane.so or https://telemetry.plane.town behind
nginx ingress with gRPC backend).
"""
import os
from urllib.parse import urlparse
# When no port in URL: https -> 443 (ingress), http -> 4317 (OTLP gRPC default)
OTLP_GRPC_DEFAULT_PORT = "4317"
HTTPS_DEFAULT_PORT = "443"
_DEFAULT_OTLP_ENDPOINT = "https://telemetry.plane.so"
def grpc_endpoint_from_url(url: str) -> str:
"""
Derive gRPC host:port from OTLP_ENDPOINT URL.
- https://telemetry.plane.so -> telemetry.plane.so:443 (nginx ingress)
- https://telemetry.plane.town -> telemetry.plane.town:443 (dev)
- telemetry.plane.so:4317 -> telemetry.plane.so:4317 (scheme-less with port)
- telemetry.plane.so -> telemetry.plane.so:4317 (scheme-less, default gRPC port)
- Explicit port in URL is always preserved.
"""
# urlparse needs a scheme to correctly populate hostname/netloc.
# Scheme-less values like "host:port" are misread as scheme="host", path="port".
if "://" not in url:
url = "//" + url
parsed = urlparse(url)
host = parsed.hostname or "telemetry.plane.so"
if parsed.port is not None:
port = str(parsed.port)
elif parsed.scheme == "https":
port = HTTPS_DEFAULT_PORT
else:
port = OTLP_GRPC_DEFAULT_PORT
return f"{host}:{port}"
def get_otlp_grpc_endpoint() -> str:
"""
Return the gRPC endpoint (host:port) for OTLP traces and metrics.
Derived from OTLP_ENDPOINT so the same URL works for both (e.g. collector
behind nginx ingress with gRPC backend on 443).
"""
base = os.environ.get("OTLP_ENDPOINT", _DEFAULT_OTLP_ENDPOINT)
return grpc_endpoint_from_url(base)
def get_otlp_http_metrics_url() -> str:
"""Return the HTTP URL for OTLP metrics (OTLP_ENDPOINT + /v1/metrics)."""
base = os.environ.get("OTLP_ENDPOINT", _DEFAULT_OTLP_ENDPOINT)
return f"{base.rstrip('/')}/v1/metrics"
+8 -13
View File
@@ -90,20 +90,15 @@ def _contains_suspicious_patterns(path: str) -> bool:
def get_allowed_hosts() -> list[str]:
"""Get the allowed hosts from the settings."""
base_origin = settings.WEB_URL or settings.APP_BASE_URL
allowed_hosts = []
if base_origin:
host = urlparse(base_origin).netloc
allowed_hosts.append(host)
if settings.ADMIN_BASE_URL:
# Get only the host
host = urlparse(settings.ADMIN_BASE_URL).netloc
allowed_hosts.append(host)
if settings.SPACE_BASE_URL:
# Get only the host
host = urlparse(settings.SPACE_BASE_URL).netloc
allowed_hosts.append(host)
# Include every configured base URL; WEB_URL and APP_BASE_URL may differ
# (e.g. WEB_URL points at the API host, APP_BASE_URL at the web app), and
# both need to be allowed for redirects to either origin to pass safety checks.
for setting in (settings.WEB_URL, settings.APP_BASE_URL, settings.ADMIN_BASE_URL, settings.SPACE_BASE_URL):
if setting:
host = urlparse(setting).netloc
if host and host not in allowed_hosts:
allowed_hosts.append(host)
return allowed_hosts
-62
View File
@@ -1,62 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
import atexit
# Third party imports
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
# Global variable to track initialization
_TRACER_PROVIDER = None
def init_tracer():
"""Initialize OpenTelemetry with proper shutdown handling"""
global _TRACER_PROVIDER
# If already initialized, return existing provider
if _TRACER_PROVIDER is not None:
return _TRACER_PROVIDER
# Configure the tracer provider
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
resource = Resource.create({"service.name": service_name})
tracer_provider = TracerProvider(resource=resource)
# Set as global tracer provider
trace.set_tracer_provider(tracer_provider)
# Configure the OTLP exporter
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
span_processor = BatchSpanProcessor(otlp_exporter)
tracer_provider.add_span_processor(span_processor)
# Initialize Django instrumentation
DjangoInstrumentor().instrument()
# Store provider globally
_TRACER_PROVIDER = tracer_provider
# Register shutdown handler
atexit.register(shutdown_tracer)
return tracer_provider
def shutdown_tracer():
"""Shutdown OpenTelemetry tracers and processors"""
global _TRACER_PROVIDER
if _TRACER_PROVIDER is not None:
if hasattr(_TRACER_PROVIDER, "shutdown"):
_TRACER_PROVIDER.shutdown()
_TRACER_PROVIDER = None
+272
View File
@@ -0,0 +1,272 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
SSRF-safe outbound HTTP client.
The validators in :mod:`plane.utils.ip_address` resolve a hostname and confirm
that none of its addresses point at internal infrastructure. On their own they
are vulnerable to DNS rebinding (TOCTOU): the validator resolves the name, but
``requests`` resolves it a *second* time when it actually connects, and an
attacker who controls DNS can return a public IP to the validator and an
internal IP to the connection.
``pinned_fetch`` closes that window by resolving + validating once and then
connecting to the *validated IP literal* — urllib3 performs no second DNS
lookup, so the address that was checked is exactly the address that is reached.
The original hostname is still used for the ``Host`` header, TLS SNI and
certificate verification, so virtual-hosting and HTTPS continue to work.
Redirects are never auto-followed (``requests`` would re-resolve each hop and
reopen the rebinding window, and a ``Location`` can point at a new internal
host). ``pinned_fetch_following_redirects`` follows them manually, re-resolving,
re-validating and re-pinning every hop.
"""
# Python imports
import ipaddress
from urllib.parse import unquote, urljoin, urlsplit
# Third party imports
import requests
from requests.adapters import HTTPAdapter
# Module imports
from plane.utils.ip_address import resolve_and_validate
# 3xx status codes that carry a Location we may follow.
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
# Never route through an ambient proxy — a CONNECT to a proxy would tunnel to
# the original hostname and bypass the IP pinning entirely.
_NO_PROXIES = {"http": None, "https": None}
class PinnedIPAdapter(HTTPAdapter):
"""
A ``requests`` transport adapter that connects to whatever IP literal is in
the request URL while presenting ``server_hostname`` for TLS SNI and
certificate verification.
The IP literal in the URL means urllib3 opens the socket to that exact IP
with no DNS resolution. Injecting ``server_hostname`` (and leaving
``assert_hostname`` at its ``None`` default so ``SSLContext.check_hostname``
stays ``True``) makes the standard library verify the presented certificate
against the real hostname rather than the IP.
Instances hold no global state — one is mounted on a throwaway
:class:`requests.Session` per request, so this is safe under any Celery pool
(prefork / threads / gevent).
"""
def __init__(self, server_hostname, *args, **kwargs):
self._server_hostname = server_hostname
super().__init__(*args, **kwargs)
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
# requests >= 2.32 calls this (it replaced get_connection() as part of
# the CVE-2024-35195 fix). requests is pinned to 2.33 in base.txt.
host_params, pool_kwargs = self.build_connection_pool_key_attributes(
request, verify, cert
)
# server_hostname is a recognised urllib3 SSL pool-key field, so pools
# for different hostnames don't collide.
pool_kwargs["server_hostname"] = self._server_hostname
return self.poolmanager.connection_from_host(**host_params, pool_kwargs=pool_kwargs)
def _split_target(url):
"""Parse a URL into the pieces needed to build a pinned request.
Returns ``(scheme, hostname, port, path, auth)`` where ``auth`` carries any
URL-embedded credentials (``user:pass@host``) as a ``(user, pass)`` tuple so
HTTP Basic Auth still works once the URL is rewritten to an IP literal.
"""
parts = urlsplit(url)
scheme = parts.scheme
if scheme not in ("http", "https"):
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
hostname = parts.hostname
if not hostname:
raise ValueError("Invalid URL: No hostname found")
port = parts.port or (443 if scheme == "https" else 80)
path = parts.path or "/"
if parts.query:
path = f"{path}?{parts.query}"
auth = None
if parts.username is not None or parts.password is not None:
auth = (unquote(parts.username or ""), unquote(parts.password or ""))
return scheme, hostname, port, path, auth
def _request_to_ip(method, scheme, hostname, ip, port, path, *, headers, timeout, auth=None, **kwargs):
"""Issue a single request whose socket is pinned to ``ip``.
With ``stream=True`` the session is kept open until the caller closes the
response (closing the response also closes the session), so a streamed body
can be read with a real size cap; otherwise the session is closed eagerly.
"""
ip_obj = ipaddress.ip_address(ip)
host_for_url = f"[{ip}]" if ip_obj.version == 6 else ip
url = f"{scheme}://{host_for_url}:{port}{path}"
request_headers = dict(headers or {})
default_port = 443 if scheme == "https" else 80
# Host header (and TLS) carry the ORIGINAL hostname, not the IP literal.
# An IPv6-literal hostname must be bracketed in the Host header.
host_label = f"[{hostname}]" if ":" in hostname else hostname
request_headers["Host"] = host_label if port == default_port else f"{host_label}:{port}"
session = requests.Session()
session.trust_env = False # ignore ambient proxy / netrc / env (see _NO_PROXIES)
if scheme == "https":
session.mount("https://", PinnedIPAdapter(server_hostname=hostname))
try:
response = session.request(
method,
url,
headers=request_headers,
timeout=timeout,
allow_redirects=False,
verify=True,
proxies=_NO_PROXIES,
auth=auth,
**kwargs,
)
except BaseException:
session.close()
raise
if kwargs.get("stream"):
# Defer closing the session until the response is closed, so the
# streamed body remains readable. response.close() now also closes
# the session.
_orig_close = response.close
def _close_all(_orig=_orig_close, _sess=session):
try:
_orig()
finally:
_sess.close()
response.close = _close_all
else:
session.close()
return response
def _fetch_validated_hop(method, url, *, allowed_ips, allowed_hosts, headers, timeout, **kwargs):
"""
Resolve ``url``'s host, validate it, then issue a single (non-redirecting)
request pinned to a resolved IP. Returns ``(response, normalized_host)``.
Hosts in ``allowed_hosts`` are operator-trusted (e.g. internal service DNS
whose IPs are dynamic): they skip the private-IP *block* check, but the
connection is STILL pinned to the resolved IP so a trusted hostname cannot
be rebound to a different internal target between validation and connect.
"""
scheme, hostname, port, path, auth = _split_target(url)
normalized_host = hostname.rstrip(".").lower()
trusted = bool(allowed_hosts) and normalized_host in {
(h or "").rstrip(".").lower() for h in allowed_hosts if h
}
# Resolve once (and validate unless the host is operator-trusted), then pin
# the connection to a resolved IP literal — urllib3 performs no second DNS
# lookup, so the address validated here is exactly the one reached.
ips = resolve_and_validate(hostname, allowed_ips=allowed_ips, require_safe=not trusted)
last_exc = None
for ip in ips:
try:
response = _request_to_ip(
method, scheme, hostname, ip, port, path,
headers=headers, timeout=timeout, auth=auth, **kwargs,
)
return response, normalized_host
except requests.RequestException as exc:
# Try the next resolved address (dual-stack / round-robin hosts).
last_exc = exc
if last_exc is not None:
raise last_exc
raise requests.ConnectionError(f"No reachable address for host: {hostname}")
def pinned_fetch(
method,
url,
*,
allowed_ips=None,
allowed_hosts=None,
headers=None,
timeout=30,
**kwargs,
):
"""
SSRF-safe single request. Resolves + validates the target host and pins the
connection to a validated IP (defeating DNS rebinding). Does NOT follow
redirects.
Raises:
ValueError: if the URL is invalid or resolves to a blocked address.
requests.RequestException: on network/transport errors.
"""
response, _ = _fetch_validated_hop(
method, url,
allowed_ips=allowed_ips, allowed_hosts=allowed_hosts,
headers=headers, timeout=timeout, **kwargs,
)
return response
def pinned_fetch_following_redirects(
method,
url,
*,
allowed_ips=None,
allowed_hosts=None,
headers=None,
timeout=30,
max_redirects=5,
**kwargs,
):
"""
SSRF-safe request that follows redirects manually, re-resolving,
re-validating and re-pinning every hop. Returns ``(response, final_url)``.
Raises:
ValueError: if any URL in the chain is invalid or resolves to a blocked
address.
requests.TooManyRedirects: if the hop limit is exceeded.
requests.RequestException: on network/transport errors.
"""
current_url = url
redirects = 0
while True:
response, _ = _fetch_validated_hop(
method, current_url,
allowed_ips=allowed_ips, allowed_hosts=allowed_hosts,
headers=headers, timeout=timeout, **kwargs,
)
if response.status_code not in _REDIRECT_STATUSES:
return response, current_url
location = response.headers.get("Location")
if not location:
return response, current_url
if redirects >= max_redirects:
response.close()
raise requests.TooManyRedirects(
f"Exceeded {max_redirects} redirects for URL: {url}"
)
redirects += 1
# Release the intermediate hop's connection/session before following.
response.close()
# Resolve the redirect target against the current URL; the next loop
# iteration re-validates and re-pins it.
current_url = urljoin(current_url, location)
+6 -2
View File
@@ -9,8 +9,6 @@ psycopg==3.3.0
psycopg-binary==3.3.0
psycopg-c==3.3.0
dj-database-url==2.1.0
# mongo
pymongo==4.6.3
# redis
redis==5.0.4
django-redis==5.4.0
@@ -56,6 +54,11 @@ cryptography==46.0.7
lxml==6.1.0
# s3
boto3==1.34.96
# http client (pinned to address CVE-2026-44431 and CVE-2026-44432)
urllib3>=2.7.0
# requests — used directly for webhook delivery & link unfurling; pinned to
# >=2.32 for the get_connection_with_tls_context adapter hook (SSRF IP pinning)
requests==2.33.0
# password validator
zxcvbn==4.4.28
# timezone
@@ -67,6 +70,7 @@ opentelemetry-api==1.28.1
opentelemetry-sdk==1.28.1
opentelemetry-instrumentation-django==0.49b1
opentelemetry-exporter-otlp==1.28.1
opentelemetry-exporter-otlp-proto-grpc==1.28.1
# OpenAPI Specification
drf-spectacular==0.28.0
# html sanitizer
+1 -2
View File
@@ -8,5 +8,4 @@ pytest-mock==3.11.1
factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.33.0
httpx==0.24.1
+82
View File
@@ -0,0 +1,82 @@
# Running the API Test Suite
This guide covers running the Django/pytest suite for `apps/api` inside Docker via `docker-compose-test.yml` at the repo root. The compose file boots an isolated stack — Postgres, Valkey (Redis), RabbitMQ, MinIO — with tmpfs-backed data dirs, so every run begins from a clean slate and a single teardown command removes everything.
For background on the test layout, markers, and fixtures, see [`TESTING_GUIDE.md`](./TESTING_GUIDE.md) and [`README.md`](./README.md).
## Prerequisites
- Docker and Docker Compose v2 (`docker compose ...`)
- Env files generated via the setup script:
```bash
./setup.sh
```
This copies `apps/api/.env.example` → `apps/api/.env` (along with the other app env files). The compose file reads `apps/api/.env`, so this step must run **before** the first `docker compose` invocation.
## Running the suite
All commands are run from the repo root.
### Full suite
```bash
docker compose -f docker-compose-test.yml up \
--build \
--abort-on-container-exit \
--exit-code-from api-tests
```
- `--build` rebuilds the `api-tests` image when `Dockerfile.dev` or `requirements/*.txt` change.
- `--abort-on-container-exit` stops the dependency services as soon as `api-tests` exits.
- `--exit-code-from api-tests` propagates pytest's exit code so this works in CI.
### Filtered runs
Use `docker compose run` to override the default `pytest` command. Anything you pass after the service name is forwarded to pytest.
```bash
# Only unit tests (marker defined in pytest.ini)
docker compose -f docker-compose-test.yml run --rm --build api-tests pytest -m unit
# A single directory, filtered by name
docker compose -f docker-compose-test.yml run --rm api-tests \
pytest plane/tests/unit -k "test_workspace"
# Single file with verbose output
docker compose -f docker-compose-test.yml run --rm api-tests \
pytest plane/tests/unit/models/test_workspace.py -vv
```
The available markers (`unit`, `contract`, `smoke`, `slow`) are declared in `apps/api/pytest.ini`.
### Teardown
```bash
docker compose -f docker-compose-test.yml down -v
```
`-v` removes the ephemeral volumes and the `test_env` network. Because the data directories are tmpfs, no host state survives a teardown — every run starts clean. Run this between unrelated test sessions to free Docker resources.
## How it works
| Service | Image | Purpose |
| ------------ | ------------------------------------ | --------------------------------------------- |
| `test-db` | `postgres:15.7-alpine` | Application database |
| `test-redis` | `valkey/valkey:7.2.11-alpine` | Cache / Celery broker |
| `test-mq` | `rabbitmq:3.13.6-management-alpine` | Task queue |
| `test-minio` | `minio/minio` | S3-compatible object storage |
| `api-tests` | built from `apps/api/Dockerfile.dev` | Installs `requirements/test.txt`, runs pytest |
All four dependencies expose health checks; `api-tests` waits for `service_healthy` on each via `depends_on`, so pytest only starts once the stack is ready.
Test-time env overrides live in the compose file itself (`POSTGRES_HOST=test-db`, `REDIS_URL=redis://test-redis:6379/`, `AWS_S3_ENDPOINT_URL=http://test-minio:9000`, `DJANGO_SETTINGS_MODULE=plane.settings.test`). Everything else is inherited from `apps/api/.env`.
## Troubleshooting
- **`./apps/api/.env: no such file or directory`** — run `./setup.sh` from the repo root.
- **Port already in use** — none of the test services publish host ports; if you see this it's coming from a different compose stack. Stop the local stack (`docker compose -f docker-compose-local.yml down`).
- **Stale image after dependency changes** — rebuild explicitly: `docker compose -f docker-compose-test.yml build --no-cache api-tests`.
- **MinIO bucket missing** — the `test-minio` entrypoint creates the bucket named by `AWS_S3_BUCKET_NAME` (default `uploads`). Change the value in `apps/api/.env` and re-run.
- **Database state leaking between runs** — confirm you ran `down -v` (not just `down`). The tmpfs mounts are torn down with the container, but the network and any externally created volumes need `-v` to clear.
+10 -1
View File
@@ -15,7 +15,7 @@ RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.9.4
ARG TURBO_VERSION=2.9.14
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=live --docker
@@ -54,12 +54,21 @@ RUN pnpm turbo run build --filter=live
FROM base AS runner
WORKDIR /app
# Remove go from Alpine APK database; not needed at runtime and carries stdlib CVEs
RUN apk del go 2>/dev/null || true
# Remove vulnerable picomatch bundled inside npm (CVE-2026-33671); npm is not used at runtime
RUN rm -rf /usr/local/lib/node_modules/npm/node_modules/picomatch
COPY --from=installer /app/packages ./packages
COPY --from=installer /app/apps/live/dist ./apps/live/dist
COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules
COPY --from=installer /app/node_modules ./node_modules
COPY --from=installer /app/apps/live/package.json ./apps/live/package.json
# esbuild and tsgolint are build-only Go binaries; remove from runtime image to eliminate stdlib CVEs
RUN find /app/node_modules \( -name 'esbuild' -o -name 'tsgolint' \) -type f -delete 2>/dev/null || true
ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000
+32 -31
View File
@@ -27,54 +27,55 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@effect/platform": "^0.94.0",
"@effect/platform-node": "^0.104.0",
"@fontsource/inter": "5.2.8",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
"@hocuspocus/server": "2.15.2",
"@hocuspocus/transformer": "2.15.2",
"@effect/platform": "catalog:",
"@effect/platform-node": "catalog:",
"@fontsource/inter": "catalog:",
"@hocuspocus/extension-database": "catalog:",
"@hocuspocus/extension-logger": "catalog:",
"@hocuspocus/extension-redis": "catalog:",
"@hocuspocus/server": "catalog:",
"@hocuspocus/transformer": "catalog:",
"@plane/decorators": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
"@plane/types": "workspace:*",
"@react-pdf/renderer": "^4.3.0",
"@react-pdf/types": "^2.9.2",
"@plane/utils": "workspace:*",
"@react-pdf/renderer": "catalog:",
"@react-pdf/types": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/html": "catalog:",
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"compression": "catalog:",
"cors": "catalog:",
"dotenv": "catalog:",
"effect": "3.20.0",
"effect": "catalog:",
"express": "catalog:",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "5.7.0",
"express-ws": "catalog:",
"helmet": "catalog:",
"ioredis": "catalog:",
"react": "catalog:",
"sharp": "^0.34.3",
"sharp": "catalog:",
"uuid": "catalog:",
"ws": "^8.18.3",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20",
"zod": "^3.25.76"
"ws": "catalog:",
"y-prosemirror": "catalog:",
"y-protocols": "catalog:",
"yjs": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@plane/typescript-config": "workspace:*",
"@types/compression": "1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "4.17.23",
"@types/express-ws": "^3.0.5",
"@types/compression": "catalog:",
"@types/cors": "catalog:",
"@types/express": "catalog:",
"@types/express-ws": "catalog:",
"@types/node": "catalog:",
"@types/pdf-parse": "^1.1.5",
"@types/pdf-parse": "catalog:",
"@types/react": "catalog:",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.8",
"pdf-parse": "^2.4.5",
"@types/ws": "catalog:",
"@vitest/coverage-v8": "catalog:",
"pdf-parse": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "^4.0.8"
"vitest": "catalog:"
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
*/
import type { AxiosInstance } from "axios";
import axios from "axios";
import { create } from "axios";
import { env } from "@/env";
import { AppError } from "@/lib/errors";
@@ -16,7 +16,7 @@ export abstract class APIService {
constructor(baseURL?: string) {
this.baseURL = baseURL || env.API_BASE_URL;
this.axiosInstance = axios.create({
this.axiosInstance = create({
baseURL: this.baseURL,
withCredentials: true,
timeout: 20000,
+9 -4
View File
@@ -1,13 +1,18 @@
FROM caddy:2.10.0-builder-alpine AS caddy-builder
FROM caddy:2.11.3-builder-alpine AS caddy-builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare@v0.2.1 \
--with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \
--with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4
--with github.com/mholt/caddy-l4@6faae83b167fda94e62b686be5cbeb9b3f8fe002 \
--with github.com/go-jose/go-jose/v3@v3.0.5 \
--with github.com/go-jose/go-jose/v4@v4.1.4 \
--with google.golang.org/grpc@v1.80.0 \
--with go.opentelemetry.io/otel@v1.43.0 \
--with go.opentelemetry.io/otel/sdk@v1.43.0
FROM caddy:2.10.0-alpine
FROM caddy:2.11.3-alpine
RUN apk add --no-cache nss-tools bash curl
RUN apk update && apk upgrade --no-cache && apk add --no-cache nss-tools bash curl
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
+13 -1
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.9.4
RUN pnpm add -g turbo@2.9.14
COPY . .
@@ -78,10 +78,22 @@ RUN pnpm turbo run build --filter=space
FROM base AS runner
ENV NODE_ENV=production
# Remove go from Alpine APK database; not needed at runtime and carries stdlib CVEs
RUN apk del go 2>/dev/null || true
# Remove vulnerable picomatch bundled inside npm (CVE-2026-33671)
# npx only needs picomatch when installing packages, not when running a locally-installed binary
RUN rm -rf /usr/local/lib/node_modules/npm/node_modules/picomatch
COPY --from=installer /app/apps/space/build ./apps/space/build
COPY --from=installer /app/apps/space/node_modules ./apps/space/node_modules
COPY --from=installer /app/node_modules ./node_modules
# esbuild and tsgolint are build-only Go binaries; remove from runtime image to eliminate stdlib CVEs
RUN find /app/node_modules \( -name 'esbuild' -o -name 'tsgolint' \) -type f -delete 2>/dev/null || true
WORKDIR /app/apps/space
EXPOSE 3000
+1 -1
View File
@@ -11,7 +11,7 @@ http {
set_real_ip_from 0.0.0.0/0;
real_ip_recursive on;
real_ip_header X-Forward-For;
real_ip_header X-Forwarded-For;
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
access_log /dev/stdout;
+15 -14
View File
@@ -18,10 +18,10 @@
},
"dependencies": {
"@bprogress/core": "catalog:",
"@fontsource-variable/inter": "5.2.8",
"@fontsource/ibm-plex-mono": "5.2.7",
"@fontsource/material-symbols-rounded": "5.2.30",
"@headlessui/react": "^1.7.19",
"@fontsource-variable/inter": "catalog:",
"@fontsource/ibm-plex-mono": "catalog:",
"@fontsource/material-symbols-rounded": "catalog:",
"@headlessui/react": "catalog:",
"@plane/constants": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/i18n": "workspace:*",
@@ -30,24 +30,24 @@
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@popperjs/core": "^2.11.8",
"@popperjs/core": "catalog:",
"@react-router/node": "catalog:",
"@react-router/serve": "catalog:",
"axios": "catalog:",
"clsx": "^2.0.0",
"date-fns": "^4.1.0",
"isbot": "^5.1.31",
"clsx": "catalog:",
"date-fns": "catalog:",
"isbot": "catalog:",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"mobx-utils": "catalog:",
"next-themes": "0.4.6",
"next-themes": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-dropzone": "^14.2.3",
"react-hook-form": "7.51.5",
"react-popper": "^2.3.0",
"react-dropzone": "catalog:",
"react-hook-form": "catalog:",
"react-popper": "catalog:",
"react-router": "catalog:",
"swr": "catalog:",
"uuid": "catalog:"
@@ -56,7 +56,8 @@
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "catalog:",
"@tailwindcss/typography": "0.5.19",
"@tailwindcss/postcss": "catalog:",
"@tailwindcss/typography": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
@@ -64,6 +65,6 @@
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "catalog:"
}
}
+4 -2
View File
@@ -14,7 +14,7 @@ RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.9.4
ARG TURBO_VERSION=2.9.14
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
@@ -75,7 +75,9 @@ RUN pnpm turbo run build --filter=web
# *****************************************************************************
# STAGE 3: Serve with nginx
# *****************************************************************************
FROM nginx:1.27-alpine AS production
FROM nginx:1.29-alpine AS production
RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*
COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
+4
View File
@@ -8,6 +8,10 @@ import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import polyfills from "@/lib/polyfills";
void polyfills;
startTransition(() => {
hydrateRoot(
document,
@@ -133,7 +133,7 @@ export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps)
<div className="relative grid h-3/5 w-4/5 grid-cols-10 overflow-hidden rounded-[10px] bg-surface-1 sm:h-3/4 md:w-1/2 lg:w-3/5">
<button
type="button"
className="fixed top-[19%] right-[9%] z-10 translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border border-strong p-1 sm:top-[11.5%] md:right-[24%] lg:right-[19%]"
className="fixed top-[19%] right-[9%] z-10 translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border border-strong bg-surface-1 p-1 sm:top-[11.5%] md:right-[24%] lg:right-[19%]"
onClick={onComplete}
>
<CloseIcon className="border-strong- h-3 w-3 text-primary" />
@@ -110,7 +110,7 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
if (setToFavorite) {
handleAddToFavorites(res.id);
}
handleNextStep(res.id);
return handleNextStep(res.id);
})
.catch((err) => {
try {
@@ -119,8 +119,9 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
const nameError = errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST");
const identifierError = errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST");
const nameSpecialCharError = errorData?.name?.includes("PROJECT_NAME_CANNOT_CONTAIN_SPECIAL_CHARACTERS");
if (nameError || identifierError) {
if (nameError || identifierError || nameSpecialCharError) {
if (nameError) {
setToast({
type: TOAST_TYPE.ERROR,
@@ -136,6 +137,14 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
message: t("project_identifier_already_taken"),
});
}
if (nameSpecialCharError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_name_cannot_contain_special_characters"),
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
@@ -108,7 +108,7 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
}}
yAxis={{
key: "count",
label: t("common.no_of", { entity: isEpic ? t("epics") : t("work_items") }),
label: t("common.no_of", { entity: isEpic ? t("common.epics") : t("work_items") }),
offset: -60,
dx: -24,
}}
@@ -78,45 +78,43 @@ export const CommentQuickActions = observer(function CommentQuickActions(props:
icon: TrashIcon,
shouldRender: canDelete,
},
];
].filter((item) => item.shouldRender !== false);
},
[t, setEditMode, canEdit, showCopyLinkOption, activityOperations, comment, showAccessSpecifier, canDelete]
);
if (MENU_ITEMS.length === 0) return null;
return (
<CustomMenu customButton={<IconButton icon={MoreHorizontal} variant="ghost" size="sm" />} closeOnSelect>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => item.action()}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
{MENU_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={() => item.action()}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("size-3 shrink-0", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("size-3 shrink-0", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
});
@@ -61,8 +61,8 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
title: t("auth.sign_out.toast.error.title"),
message: t("auth.sign_out.toast.error.message"),
})
);
};
@@ -7,6 +7,7 @@
import type { ReactNode, MutableRefObject } from "react";
import React, { useState, useRef, useEffect } from "react";
import { cn } from "@plane/utils";
import { runIdleTask } from "@/lib/idle-task";
type Props = {
defaultHeight?: string;
@@ -19,7 +20,7 @@ type Props = {
placeholderChildren?: ReactNode;
defaultValue?: boolean;
shouldRecordHeights?: boolean;
useIdletime?: boolean;
useIdleTime?: boolean;
forceRender?: boolean;
};
@@ -36,25 +37,29 @@ function RenderIfVisible(props: Props) {
//placeholder children
placeholderChildren = null, //placeholder children
defaultValue = false,
useIdletime = false,
useIdleTime = false,
forceRender = false,
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(defaultValue);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
const visibilityIdleTaskRef = useRef<ReturnType<typeof runIdleTask> | null>(null);
const heightIdleTaskRef = useRef<ReturnType<typeof runIdleTask> | null>(null);
const isVisible = shouldVisible || forceRender;
// Set visibility with intersection observer
useEffect(() => {
if (intersectionRef.current) {
const target = intersectionRef.current;
if (target) {
const observer = new IntersectionObserver(
(entries) => {
//DO no remove comments for future
if (typeof window !== undefined && window.requestIdleCallback && useIdletime) {
window.requestIdleCallback(() => setShouldVisible(entries[entries.length - 1].isIntersecting), {
timeout: 300,
});
if (typeof window !== "undefined" && useIdleTime) {
visibilityIdleTaskRef.current?.cancel();
visibilityIdleTaskRef.current = runIdleTask(() =>
setShouldVisible(entries[entries.length - 1].isIntersecting)
);
} else {
setShouldVisible(entries[entries.length - 1].isIntersecting);
}
@@ -64,23 +69,27 @@ function RenderIfVisible(props: Props) {
rootMargin: `${verticalOffset}% ${horizontalOffset}% ${verticalOffset}% ${horizontalOffset}%`,
}
);
observer.observe(intersectionRef.current);
observer.observe(target);
return () => {
if (intersectionRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
observer.unobserve(intersectionRef.current);
}
visibilityIdleTaskRef.current?.cancel();
visibilityIdleTaskRef.current = null;
observer.unobserve(target);
};
}
}, [intersectionRef, children, root, verticalOffset, horizontalOffset]);
}, [intersectionRef, root, verticalOffset, horizontalOffset, useIdleTime]);
//Set height after render
useEffect(() => {
if (intersectionRef.current && isVisible && shouldRecordHeights) {
window.requestIdleCallback(() => {
heightIdleTaskRef.current?.cancel();
heightIdleTaskRef.current = runIdleTask(() => {
if (intersectionRef.current) placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
});
}
return () => {
heightIdleTaskRef.current?.cancel();
heightIdleTaskRef.current = null;
};
}, [isVisible, intersectionRef, shouldRecordHeights]);
const child = isVisible ? <>{children}</> : placeholderChildren;
@@ -117,7 +117,7 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() {
<div className="mt-5 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Save Theme Button */}
<Button variant="primary" size="lg" type="submit" loading={isSubmitting || isLoadingPalette}>
{isSubmitting ? t("creating_theme") : isLoadingPalette ? "Generating" : t("set_theme")}
{isSubmitting ? t("common.saving") : isLoadingPalette ? "Generating" : t("set_theme")}
</Button>
{/* Import/Export Section */}
<CustomThemeDownloadConfigButton getValues={getValues} />
@@ -100,7 +100,7 @@ export const IssueDetailQuickActions = observer(function IssueDetailQuickActions
router.push(redirectionPath);
} catch (_error) {
setToast({
title: t("toast.error "),
title: t("toast.error"),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
@@ -204,7 +204,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
/>
}
defaultValue={groupIndex < 5 && subGroupIndex < 2}
useIdletime
useIdleTime
>
<KanbanGroup
groupId={subList.id}
@@ -127,7 +127,7 @@ export const ModuleAnalyticsProgress = observer(function ModuleAnalyticsProgress
{isModuleDateValid ? (
<div className="relative flex w-full items-center justify-between gap-2">
<Disclosure.Button className="relative flex w-full items-center gap-2">
<div className="text-13 font-medium text-secondary">{t("progress")}</div>
<div className="text-13 font-medium text-secondary">{t("common.progress")}</div>
{progressHeaderPercentage > 0 && (
<div className="bg-amber-500/20 text-amber-500 flex h-5 w-9 items-center justify-center rounded-sm text-11 font-medium">{`${progressHeaderPercentage}%`}</div>
)}
@@ -230,7 +230,7 @@ export const CustomizeNavigationDialog = observer(function CustomizeNavigationDi
{/* Workspace Section */}
<div className="flex flex-col gap-2">
<h3 className="text-13 font-semibold text-placeholder">{t("workspace")}</h3>
<h3 className="text-13 font-semibold text-placeholder">{t("common.workspace")}</h3>
<div className="rounded-md border border-subtle bg-surface-2 py-2">
{/* Pinned Items - Draggable */}
<Sortable
@@ -30,8 +30,8 @@ export const usePowerKAccountCommands = (): TPowerKCommandConfig[] => {
signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
title: t("auth.sign_out.toast.error.title"),
message: t("auth.sign_out.toast.error.message"),
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1,194 +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 { useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
// icons
import { History, MessageSquare } from "lucide-react";
import { calculateTimeAgo, getFileURL } from "@plane/utils";
// hooks
import { ActivityIcon, ActivityMessage } from "@/components/core/activity";
import { RichTextEditor } from "@/components/editor/rich-text";
import { ActivitySettingsLoader } from "@/components/ui/loader/settings/activity";
// constants
import { USER_ACTIVITY } from "@/constants/fetch-keys";
// hooks
import { useUser } from "@/hooks/store/user";
// services
import { UserService } from "@/services/user.service";
const userService = new UserService();
type Props = {
cursor: string;
perPage: number;
updateResultsCount: (count: number) => void;
updateTotalPages: (count: number) => void;
updateEmptyState: (state: boolean) => void;
};
export const ProfileActivityListPage = observer(function ProfileActivityListPage(props: Props) {
const { cursor, perPage, updateResultsCount, updateTotalPages, updateEmptyState } = props;
// store hooks
const { data: currentUser } = useUser();
const { data: userProfileActivity } = useSWR(
USER_ACTIVITY({
cursor,
}),
() =>
userService.getUserActivity({
cursor,
per_page: perPage,
})
);
useEffect(() => {
if (!userProfileActivity) return;
// if no results found then show empty state
if (userProfileActivity.total_results === 0) updateEmptyState(true);
updateTotalPages(userProfileActivity.total_pages);
updateResultsCount(userProfileActivity.results.length);
}, [updateResultsCount, updateTotalPages, userProfileActivity, updateEmptyState]);
// TODO: refactor this component
return (
<>
{userProfileActivity ? (
<ul role="list">
{userProfileActivity.results.map((activityItem: any) => {
if (activityItem.field === "comment")
return (
<div key={activityItem.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activityItem.field ? (
activityItem.new_value === "restore" && <History className="h-3.5 w-3.5 text-secondary" />
) : activityItem.actor_detail.avatar_url && activityItem.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(activityItem.actor_detail.avatar_url)}
alt={activityItem.actor_detail.display_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3"
/>
) : (
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3 capitalize">
{activityItem.actor_detail.display_name?.[0]}
</div>
)}
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-layer-3 p-2 text-secondary">
<MessageSquare className="!text-20 text-secondary" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-11">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-11 text-secondary">
Commented {calculateTimeAgo(activityItem.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RichTextEditor
editable={false}
id={activityItem.id}
initialValue={
activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value
}
containerClassName="text-11 bg-surface-1"
workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""}
workspaceSlug={activityItem?.workspace_detail?.slug?.toString() ?? ""}
projectId={activityItem.project ?? ""}
/>
</div>
</div>
</div>
</div>
);
const message = <ActivityMessage activity={activityItem} showIssue />;
if ("field" in activityItem && activityItem.field !== "updated_by")
return (
<li key={activityItem.id}>
<div className="relative pb-1">
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative mt-4 px-1.5">
<div className="mt-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-lg border border-subtle shadow-raised-100">
{activityItem.field ? (
activityItem.new_value === "restore" ? (
<History className="h-5 w-5 text-secondary" />
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar_url &&
activityItem.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(activityItem.actor_detail.avatar_url)}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="h-full w-full rounded-full object-cover"
/>
) : (
<div className="grid h-6 w-6 place-items-center rounded-full border-2 border-subtle-1 bg-layer-3 text-11 capitalize">
{activityItem.actor_detail.display_name?.[0]}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 border-b border-subtle py-4">
<div className="text-caption-md-regular break-words text-secondary">
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
) : (
<Link
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
className="inline"
>
<span className="text-gray font-medium">
{currentUser?.id === activityItem.actor_detail.id
? "You"
: activityItem.actor_detail.display_name}
</span>
</Link>
)}{" "}
<div className="inline gap-1">
{message}{" "}
<span className="flex-shrink-0 whitespace-nowrap">
{calculateTimeAgo(activityItem.created_at)}
</span>
</div>
</div>
</div>
</>
</div>
</div>
</li>
);
})}
</ul>
) : (
<ActivitySettingsLoader />
)}
</>
);
});
@@ -54,7 +54,7 @@ export function ProfilePriorityDistribution({ userProfile }: Props) {
]}
xAxis={{
key: "name",
label: t("profile.stats.priority_distribution.priority"),
label: t("common.priority"),
}}
yAxis={{
key: "count",

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