Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7719d6f39 | |||
| 36e81f4851 | |||
| 0bbfe95cc7 | |||
| 9a30a07cf5 | |||
| b6e47ccdae | |||
| 4280c4d1b1 | |||
| b1c78fe4c8 | |||
| 7ec8d4990f | |||
| e388cb9125 | |||
| bd0d164e0b | |||
| 011328c793 | |||
| 3f57fefdb4 | |||
| 04622ce118 | |||
| 248f5d66e6 | |||
| f14451a5de | |||
| 095b1aa360 | |||
| 0acb32e65e | |||
| edf2475413 | |||
| 310d2eda21 | |||
| 13a3ea27fb | |||
| 9f77ea5ebb | |||
| e71a8f5dbb | |||
| 41b03bb142 | |||
| fd613dc738 | |||
| 039d582fbb | |||
| 4ca6d6c7b8 | |||
| 208f35964b | |||
| 50a7b47b31 | |||
| 65d6a94b0a | |||
| 7fd8e3364c | |||
| 4225bc59de | |||
| 4c1bdd1d62 | |||
| ff21e53f5a | |||
| 9491bdbe46 | |||
| a62fe8a781 | |||
| db1c5b9513 | |||
| a40e064448 |
@@ -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 2–5 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**:
|
||||
- 2–5 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`
|
||||
@@ -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
|
||||
@@ -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 1–2 sentence elaboration drawn from commit body.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
|
||||
|
||||
## 🔧 Refactor & Chore
|
||||
|
||||
- **<Short title>** — [WEB-XXXX] (#PR_NUM)
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
- Bump `<package>` X.Y.Z → A.B.C (#PR_NUM)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Lead with a bold human-readable title (rewrite the commit subject if cryptic)
|
||||
- Always include the work item ID in brackets and the merge PR number in parens
|
||||
- Add a sub-line elaboration only when the commit body has substance worth surfacing (acceptance criteria, scope notes, gotchas like "behind feature flag", "requires migration", "requires Vercel setting")
|
||||
- Drop empty sections
|
||||
|
||||
### 7. Update the PR description
|
||||
|
||||
```bash
|
||||
gh pr edit <PR_NUM> --body "$(cat <<'EOF'
|
||||
<release notes markdown>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Always use a HEREDOC with single-quoted `'EOF'` so backticks/dollars in the notes are preserved.
|
||||
|
||||
## Quick Reference: end-to-end
|
||||
|
||||
```bash
|
||||
PR=2498
|
||||
gh pr view $PR --json commits --jq '.commits[] | .messageHeadline + "\n---\n" + .messageBody + "\n==="' > /tmp/commits.txt
|
||||
# read /tmp/commits.txt, filter, categorize, draft notes
|
||||
gh pr edit $PR --body "$(cat <<'EOF'
|
||||
... release notes ...
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Including `fix: merge conflicts`** — merge artifact, no functional content
|
||||
- **Dropping the work item ID** — readers rely on `[WEB-XXXX]` to navigate to the ticket
|
||||
- **Over-enriching with MCP lookups** — work item descriptions are often empty; commit body is usually richer
|
||||
- **Missing the merge PR number** — always include `(#NNNN)` from the commit subject so reviewers can audit the source PR
|
||||
- **Using `--body` without HEREDOC** — backticks/dollar signs get shell-interpreted and corrupt the notes
|
||||
- **Editing the title** — release PR titles are version markers; only edit the body
|
||||
|
||||
## Plane-Specific Conventions
|
||||
|
||||
- Release PRs go from `canary` → `master`
|
||||
- PR title format: `release: vX.Y.Z` semver (major.minor.patch)
|
||||
- Commits coming from feature branches always carry a work item ID; commits without one are usually infra/chores
|
||||
@@ -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 1–3 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.
|
||||
@@ -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 2–4, 22–24… (gen.sg.); `many` for 0, 5–20, 25–30… (gen.pl.); `other` for non-integer counts (gen.sg.). For pl: `one` (1), `few` (2–4 not 12–14, nom.pl.), `many` (0, 5–20, gen.pl.), `other` (decimals, gen.sg.). cs/sk: `one` (1), `few` (2–4, 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, 2–19 → few; 20+ → other |
|
||||
| pl | `one, few, many, other` | 1 → one; 2–4 (not 12–14) → few; 0, 5–20, many-digit → many; decimals → other |
|
||||
| cs | `one, few, many, other` | 1 → one; 2–4 → few; decimals → many; 0, 5+ → other |
|
||||
| sk | `one, few, many, other` | same pattern as cs |
|
||||
| ru | `one, few, many, other` | 1, 21, 31… → one; 2–4, 22–24… → few; 0, 5–20, 25–30… → 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 | +10–35%, single words up to +180% | Reserve generous space on buttons, tabs, labels |
|
||||
| fr | +15–25% | |
|
||||
| es | +15–30% | |
|
||||
| it | +10–25% | |
|
||||
| pt-BR | +15–30% | |
|
||||
| pl | +20–30% | |
|
||||
| cs, sk | +10–20% | |
|
||||
| ro | +15–25% | |
|
||||
| ru, ua | +15% typical, spikes to +30% | |
|
||||
| tr-TR | +10–30% (agglutination) | |
|
||||
| vi-VN | +30–40% | Diacritic-heavy, many small words |
|
||||
| id | +10–20% | |
|
||||
| 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 2–5 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 2–4 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 2–4 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 `‏`/`‎` 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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -22,6 +22,7 @@ class APITokenSerializer(BaseSerializer):
|
||||
"is_active",
|
||||
"last_used",
|
||||
"user_type",
|
||||
"allowed_rate_limit",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,7 +81,7 @@ class WebhookEndpoint(BaseAPIView):
|
||||
serializer = WebhookSerializer(
|
||||
webhook,
|
||||
data=request.data,
|
||||
context={request: request},
|
||||
context={"request": request},
|
||||
partial=True,
|
||||
fields=(
|
||||
"id",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user