Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e7dcda41 | |||
| 5017534e5c | |||
| 6e5031196b | |||
| bcfefea323 | |||
| 3927fbd0c7 | |||
| 74320c1062 | |||
| 2acba2980b |
@@ -1,67 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
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,200 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,608 +0,0 @@
|
||||
---
|
||||
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,7 @@
|
||||
[codespell]
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
|
||||
check-hidden = true
|
||||
# ignore all CamelCase and camelCase
|
||||
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
|
||||
ignore-words-list = tread
|
||||
@@ -0,0 +1,55 @@
|
||||
version: 2
|
||||
updates:
|
||||
# JavaScript/TypeScript dependencies (pnpm monorepo root)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "javascript"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Python dependencies
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/apps/api"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Docker - API
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/apps/api"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
branch_build_push_admin:
|
||||
name: Build-Push Admin Docker Image
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Admin Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Web Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Space Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Live Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Backend Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Proxy Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.0.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
- branch_build_push_proxy
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare AIO Assets
|
||||
id: prepare_aio_assets
|
||||
@@ -298,13 +298,13 @@ jobs:
|
||||
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AIO Assets
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: ./deployments/aio/community/dist
|
||||
name: aio-assets-dist
|
||||
|
||||
- name: AIO Build and Push
|
||||
uses: makeplane/actions/build-push@v1.4.0
|
||||
uses: makeplane/actions/build-push@v1.1.0
|
||||
with:
|
||||
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
|
||||
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
- branch_build_push_proxy
|
||||
steps:
|
||||
- name: Checkout Files
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
|
||||
|
||||
- name: Upload Assets
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: community-assets
|
||||
path: |
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
@@ -391,13 +391,12 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2.6.1
|
||||
uses: softprops/action-gh-release@v2.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
with:
|
||||
tag_name: ${{ env.REL_VERSION }}
|
||||
name: ${{ env.REL_VERSION }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
draft: false
|
||||
prerelease: ${{ env.IS_PRERELEASE }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -10,13 +10,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: Get PR Branch version
|
||||
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
@@ -16,9 +16,6 @@ jobs:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
env:
|
||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: "false"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -26,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Codespell configuration is within .codespellrc
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [preview]
|
||||
pull_request:
|
||||
branches: [preview]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@v1
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- id: checkout_files
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
full_build_push:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -63,23 +63,23 @@ jobs:
|
||||
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: ${{ env.BUILDX_DRIVER }}
|
||||
version: ${{ env.BUILDX_VERSION }}
|
||||
endpoint: ${{ env.BUILDX_ENDPOINT }}
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and Push to Docker Hub
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
uses: docker/build-push-action@v6.9.0
|
||||
with:
|
||||
context: .
|
||||
file: ./aio/Dockerfile-app
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
sudo apt-get install -y python3-pip
|
||||
pip3 install awscli
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v4
|
||||
uses: tailscale/github-action@v2
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
types:
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "ready_for_review"
|
||||
- "review_requested"
|
||||
- "reopened"
|
||||
|
||||
concurrency:
|
||||
@@ -44,7 +46,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -87,7 +89,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -95,7 +97,7 @@ jobs:
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Restore Turbo cache
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
@@ -110,7 +112,7 @@ jobs:
|
||||
run: pnpm turbo run build --affected
|
||||
|
||||
- name: Save Turbo cache
|
||||
uses: actions/cache/save@v5
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
@@ -144,7 +146,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -185,7 +187,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -193,7 +195,7 @@ jobs:
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Restore Turbo cache
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Create PR on Sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "sync/**"
|
||||
|
||||
env:
|
||||
CURRENT_BRANCH: ${{ github.ref_name }}
|
||||
TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
|
||||
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
|
||||
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
|
||||
|
||||
jobs:
|
||||
create_pull_request:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for all branches and tags
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name "$ACCOUNT_USER_NAME"
|
||||
git config user.email "$ACCOUNT_USER_EMAIL"
|
||||
|
||||
- name: Setup GH CLI and Git Config
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Create PR to Target Branch
|
||||
run: |
|
||||
# get all pull requests and check if there is already a PR
|
||||
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
|
||||
if [ -n "$PR_EXISTS" ]; then
|
||||
echo "Pull Request already exists: $PR_EXISTS"
|
||||
else
|
||||
echo "Creating new pull request"
|
||||
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
|
||||
echo "Pull Request created: $PR_URL"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Sync Repositories
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GH CLI
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Push Changes to Target Repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
|
||||
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
|
||||
@@ -110,10 +110,3 @@ 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/
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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,15 +22,3 @@
|
||||
- **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.
|
||||
|
||||
+1
-8
@@ -1,8 +1 @@
|
||||
.oxlintrc.json @sriramveeraghanta @lifeiscontent
|
||||
.oxfmtrc.json @sriramveeraghanta @lifeiscontent
|
||||
apps/api/ @dheeru0198 @pablohashescobar
|
||||
apps/web/ @sriramveeraghanta
|
||||
apps/space/ @sriramveeraghanta
|
||||
apps/admin/ @sriramveeraghanta
|
||||
apps/live/ @Palanikannan1437
|
||||
deployments/ @mguptahub
|
||||
eslint.config.mjs @lifeiscontent
|
||||
@@ -10,7 +10,7 @@
|
||||
<p align="center">
|
||||
<a href="https://plane.so/"><b>Website</b></a> •
|
||||
<a href="https://forum.plane.so"><b>Forum</b></a> •
|
||||
<a href="https://x.com/planepowers"><b>X</b></a> •
|
||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV CI=1
|
||||
|
||||
RUN corepack enable pnpm
|
||||
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN pnpm add -g turbo@2.9.14
|
||||
RUN pnpm add -g turbo@2.8.12
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -77,8 +77,6 @@ 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
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Input, ToggleSwitch } from "@plane/ui";
|
||||
import { ControllerInput } from "@/components/common/controller-input";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// components
|
||||
import { IntercomConfig } from "./intercom";
|
||||
|
||||
export interface IGeneralConfigurationForm {
|
||||
instance: IInstance;
|
||||
@@ -25,13 +27,14 @@ export interface IGeneralConfigurationForm {
|
||||
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
|
||||
const { instance, instanceAdmins } = props;
|
||||
// hooks
|
||||
const { updateInstanceInfo } = useInstance();
|
||||
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
|
||||
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
} = useForm<Partial<IInstance>>({
|
||||
defaultValues: {
|
||||
instance_name: instance?.instance_name,
|
||||
@@ -42,6 +45,17 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
const onSubmit = async (formData: Partial<IInstance>) => {
|
||||
const payload: Partial<IInstance> = { ...formData };
|
||||
|
||||
// update the intercom configuration
|
||||
const isIntercomEnabled =
|
||||
instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1";
|
||||
if (!payload.is_telemetry_enabled && isIntercomEnabled) {
|
||||
try {
|
||||
await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
await updateInstanceInfo(payload)
|
||||
.then(() =>
|
||||
setToast({
|
||||
@@ -98,7 +112,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Telemetry</div>
|
||||
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div>
|
||||
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { IFormattedInstanceConfiguration } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store";
|
||||
|
||||
type TIntercomConfig = {
|
||||
isTelemetryEnabled: boolean;
|
||||
};
|
||||
|
||||
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
|
||||
const { isTelemetryEnabled } = props;
|
||||
// hooks
|
||||
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
// derived values
|
||||
const isIntercomEnabled = isTelemetryEnabled
|
||||
? instanceConfigurations
|
||||
? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"
|
||||
? true
|
||||
: false
|
||||
: undefined
|
||||
: false;
|
||||
|
||||
const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () =>
|
||||
isTelemetryEnabled ? fetchInstanceConfigurations() : null
|
||||
);
|
||||
|
||||
const initialLoader = isLoading && isIntercomEnabled === undefined;
|
||||
|
||||
const submitInstanceConfigurations = async (payload: Partial<IFormattedInstanceConfiguration>) => {
|
||||
try {
|
||||
await updateInstanceConfigurations(payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enableIntercomConfig = () => {
|
||||
void submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
|
||||
<MessageSquare className="size-5 p-0.5 text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow">
|
||||
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
|
||||
automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<ToggleSwitch
|
||||
value={isIntercomEnabled ? true : false}
|
||||
onChange={enableIntercomConfig}
|
||||
size="sm"
|
||||
disabled={!isTelemetryEnabled || isSubmitting || initialLoader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -88,7 +88,7 @@ export function HydrateFallback() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ http {
|
||||
|
||||
set_real_ip_from 0.0.0.0/0;
|
||||
real_ip_recursive on;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_header X-Forward-For;
|
||||
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
|
||||
|
||||
access_log /dev/stdout;
|
||||
|
||||
+13
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "1.3.1",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "Admin UI for Plane",
|
||||
"license": "AGPL-3.0",
|
||||
@@ -19,10 +19,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bprogress/core": "catalog:",
|
||||
"@fontsource-variable/inter": "catalog:",
|
||||
"@fontsource/ibm-plex-mono": "catalog:",
|
||||
"@fontsource/material-symbols-rounded": "catalog:",
|
||||
"@headlessui/react": "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",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
@@ -31,35 +31,34 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/node": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"@tanstack/virtual-core": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/virtual-core": "^3.13.12",
|
||||
"axios": "catalog:",
|
||||
"isbot": "catalog:",
|
||||
"isbot": "^5.1.31",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-router": "catalog:",
|
||||
"serve": "catalog:",
|
||||
"serve": "14.2.5",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "catalog:",
|
||||
"@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:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:"
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import * as dotenv from "dotenv";
|
||||
import * as dotenv from "@dotenvx/dotenvx";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.3.1",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend",
|
||||
"license": "AGPL-3.0"
|
||||
"description": "API server powering Plane's backend"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
# python imports
|
||||
import os
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
@@ -11,7 +11,48 @@ from rest_framework.throttling import SimpleRateThrottle
|
||||
|
||||
class ApiKeyRateThrottle(SimpleRateThrottle):
|
||||
scope = "api_key"
|
||||
rate = settings.API_KEY_RATE_LIMIT
|
||||
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"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
# Retrieve the API key from the request header
|
||||
|
||||
@@ -25,10 +25,6 @@ from .issue import (
|
||||
IssueCommentCreateSerializer,
|
||||
IssueLinkCreateSerializer,
|
||||
IssueLinkUpdateSerializer,
|
||||
IssueRelationCreateSerializer,
|
||||
IssueRelationResponseSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
)
|
||||
from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import (
|
||||
@@ -53,7 +49,7 @@ from .intake import (
|
||||
IntakeIssueCreateSerializer,
|
||||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .asset import (
|
||||
UserAssetUploadSerializer,
|
||||
AssetUpdateSerializer,
|
||||
|
||||
@@ -59,10 +59,8 @@ class CycleCreateSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
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)
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
|
||||
@@ -2,36 +2,20 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Estimate, EstimatePoint
|
||||
from plane.db.models import EstimatePoint
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class EstimateSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Estimate
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "deleted_at"]
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["workspace"] = self.context["workspace"]
|
||||
validated_data["project"] = self.context["project"]
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class EstimatePointSerializer(BaseSerializer):
|
||||
def validate(self, data):
|
||||
if not data:
|
||||
raise serializers.ValidationError("Estimate points are required")
|
||||
value = data.get("value")
|
||||
if value and len(value) > 20:
|
||||
raise serializers.ValidationError("Value can't be more than 20 characters")
|
||||
return data
|
||||
"""
|
||||
Serializer for project estimation points and story point values.
|
||||
|
||||
Handles numeric estimation data for work item sizing and sprint planning,
|
||||
providing standardized point values for project velocity calculations.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = EstimatePoint
|
||||
fields = "__all__"
|
||||
read_only_fields = ["estimate", "workspace", "project"]
|
||||
fields = ["id", "value"]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -20,7 +20,6 @@ from plane.db.models import (
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Label,
|
||||
ProjectMember,
|
||||
State,
|
||||
@@ -69,7 +68,7 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at", "completed_at"]
|
||||
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
|
||||
exclude = ["description_json", "description_stripped"]
|
||||
|
||||
def validate(self, data):
|
||||
@@ -480,192 +479,6 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueRelationRefSerializer(serializers.Serializer):
|
||||
"""Project-scoped reference to a related work item."""
|
||||
|
||||
project_id = serializers.UUIDField(help_text="Project containing the related work item")
|
||||
issue_id = serializers.UUIDField(help_text="ID of the related work item")
|
||||
|
||||
|
||||
class IssueRelationResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for issue relations response showing grouped relation types.
|
||||
|
||||
Each list contains project_id and issue_id pairs so clients can resolve
|
||||
cross-project relations.
|
||||
"""
|
||||
|
||||
blocking = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items blocking this issue",
|
||||
)
|
||||
blocked_by = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items this issue is blocked by",
|
||||
)
|
||||
duplicate = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Duplicate work items",
|
||||
)
|
||||
relates_to = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Related work items",
|
||||
)
|
||||
start_after = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that start after this issue",
|
||||
)
|
||||
start_before = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that start before this issue",
|
||||
)
|
||||
finish_after = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that finish after this issue",
|
||||
)
|
||||
finish_before = serializers.ListField(
|
||||
child=IssueRelationRefSerializer(),
|
||||
help_text="Work items that finish before this issue",
|
||||
)
|
||||
|
||||
|
||||
class IssueRelationCreateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for creating issue relations.
|
||||
|
||||
Creates issue relations with the specified relation type and issues.
|
||||
Validates relation types and ensures proper issue ID format.
|
||||
"""
|
||||
|
||||
RELATION_TYPE_CHOICES = [
|
||||
("blocking", "Blocking"),
|
||||
("blocked_by", "Blocked By"),
|
||||
("duplicate", "Duplicate"),
|
||||
("relates_to", "Relates To"),
|
||||
("start_before", "Start Before"),
|
||||
("start_after", "Start After"),
|
||||
("finish_before", "Finish Before"),
|
||||
("finish_after", "Finish After"),
|
||||
]
|
||||
|
||||
relation_type = serializers.ChoiceField(
|
||||
choices=RELATION_TYPE_CHOICES,
|
||||
required=True,
|
||||
help_text="Type of relationship between work items",
|
||||
)
|
||||
issues = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=True,
|
||||
min_length=1,
|
||||
help_text="Array of work item IDs to create relations with",
|
||||
)
|
||||
|
||||
def validate_issues(self, value):
|
||||
"""Validate that issues list is not empty and contains valid UUIDs."""
|
||||
if not value:
|
||||
raise serializers.ValidationError("At least one issue ID is required.")
|
||||
return value
|
||||
|
||||
|
||||
class IssueRelationRemoveSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for removing issue relations.
|
||||
|
||||
Removes existing relationships between work items by specifying
|
||||
the related issue ID.
|
||||
"""
|
||||
|
||||
related_issue = serializers.UUIDField(
|
||||
required=True, help_text="ID of the related work item to remove relation with"
|
||||
)
|
||||
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for issue relationships showing related issue details.
|
||||
|
||||
Provides comprehensive information about related issues including
|
||||
project context, sequence ID, and relationship type.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField(source="related_issue.id", read_only=True)
|
||||
project_id = serializers.UUIDField(source="related_issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="related_issue.priority", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"state_id",
|
||||
"priority",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for reverse issue relationships showing issue details.
|
||||
|
||||
Provides comprehensive information about the source issue in a relationship
|
||||
including project context, sequence ID, and relationship type.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField(source="issue.id", read_only=True)
|
||||
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
|
||||
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="issue.priority", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"type_id",
|
||||
"is_epic",
|
||||
"state_id",
|
||||
"priority",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
"""
|
||||
Serializer for work item file attachments.
|
||||
@@ -850,7 +663,6 @@ class IssueExpandSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -114,20 +114,13 @@ 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.")
|
||||
|
||||
project_lead = data.get("project_lead")
|
||||
if (
|
||||
project_lead
|
||||
and not WorkspaceMember.objects.filter(
|
||||
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(
|
||||
workspace_id=self.context["workspace_id"],
|
||||
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."})
|
||||
member_id=data.get("project_lead"),
|
||||
).exists():
|
||||
raise serializers.ValidationError("Project lead should be a user in the workspace")
|
||||
|
||||
if data.get("default_assignee", None) is not None:
|
||||
# Check if the default assignee is a member of the workspace
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from plane.api.views.estimate import (
|
||||
ProjectEstimateAPIEndpoint,
|
||||
EstimatePointListCreateAPIEndpoint,
|
||||
EstimatePointDetailAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/",
|
||||
ProjectEstimateAPIEndpoint.as_view(http_method_names=["get", "post", "patch", "delete"]),
|
||||
name="project-estimate",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
|
||||
EstimatePointListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="estimate-point-list-create",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<uuid:estimate_point_id>/",
|
||||
EstimatePointDetailAPIEndpoint.as_view(http_method_names=["patch", "delete"]),
|
||||
name="estimate-point-detail",
|
||||
),
|
||||
]
|
||||
@@ -17,7 +17,6 @@ from plane.api.views import (
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
WorkspaceIssueAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
IssueRelationListCreateAPIEndpoint,
|
||||
)
|
||||
|
||||
# Deprecated url patterns
|
||||
@@ -146,11 +145,6 @@ new_url_patterns = [
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="work-item-attachment-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/",
|
||||
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="work-item-relation-list",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns = old_url_patterns + new_url_patterns
|
||||
|
||||
@@ -29,7 +29,6 @@ from .issue import (
|
||||
IssueAttachmentListCreateAPIEndpoint,
|
||||
IssueAttachmentDetailAPIEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
IssueRelationListCreateAPIEndpoint,
|
||||
)
|
||||
|
||||
from .cycle import (
|
||||
|
||||
@@ -17,9 +17,7 @@ from drf_spectacular.utils import OpenApiExample, OpenApiRequest
|
||||
# Module Imports
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.db.models import FileAsset, User, Workspace
|
||||
from plane.app.permissions import WorkspaceUserPermission
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.api.serializers import (
|
||||
UserAssetUploadSerializer,
|
||||
@@ -116,7 +114,7 @@ class UserAssetEndpoint(BaseAPIView):
|
||||
This endpoint generates the necessary credentials for direct S3 upload.
|
||||
"""
|
||||
# get the asset key
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", False)
|
||||
@@ -289,7 +287,7 @@ class UserServerAssetEndpoint(BaseAPIView):
|
||||
necessary credentials for direct S3 upload with server-side authentication.
|
||||
"""
|
||||
# get the asset key
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", False)
|
||||
@@ -405,12 +403,6 @@ 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(
|
||||
@@ -506,7 +498,7 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||
Create a presigned URL for uploading generic assets that can be bound to entities like work items.
|
||||
Supports various file types and includes external source tracking for integrations.
|
||||
"""
|
||||
name = sanitize_filename(request.data.get("name"))
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
project_id = request.data.get("project_id")
|
||||
|
||||
@@ -22,8 +22,9 @@ 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
|
||||
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.paginator import BasePaginator
|
||||
from plane.utils.core.mixins import ReadReplicaControlMixin
|
||||
@@ -59,7 +60,19 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
|
||||
return queryset
|
||||
|
||||
def get_throttles(self):
|
||||
return [ApiKeyRateThrottle()]
|
||||
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
|
||||
|
||||
def handle_exception(self, exc):
|
||||
"""
|
||||
@@ -110,7 +123,7 @@ class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePa
|
||||
return response
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
return response
|
||||
return exc
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
# Call super to get the default response
|
||||
|
||||
@@ -305,9 +305,7 @@ 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, "project_id": project_id}
|
||||
)
|
||||
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -518,9 +516,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(
|
||||
cycle, data=request.data, partial=True, context={"request": request, "project_id": project_id}
|
||||
)
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
||||
@@ -1,291 +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 rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions.project import ProjectEntityPermission
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from plane.db.models import Estimate, EstimatePoint, Project, Workspace
|
||||
from plane.api.serializers import EstimateSerializer, EstimatePointSerializer
|
||||
from plane.utils.openapi.decorators import estimate_docs, estimate_point_docs
|
||||
from plane.utils.openapi import (
|
||||
ESTIMATE_CREATE_EXAMPLE,
|
||||
ESTIMATE_UPDATE_EXAMPLE,
|
||||
ESTIMATE_POINT_CREATE_EXAMPLE,
|
||||
ESTIMATE_POINT_UPDATE_EXAMPLE,
|
||||
ESTIMATE_EXAMPLE,
|
||||
ESTIMATE_POINT_EXAMPLE,
|
||||
DELETED_RESPONSE,
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
ESTIMATE_ID_PARAMETER,
|
||||
)
|
||||
|
||||
|
||||
class ProjectEstimateAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = Estimate
|
||||
serializer_class = EstimateSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(workspace__slug=self.workspace_slug, project_id=self.project_id)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="create_estimate",
|
||||
summary="Create an estimate",
|
||||
description="Create an estimate for a project",
|
||||
request=OpenApiRequest(
|
||||
request=EstimateSerializer,
|
||||
examples=[ESTIMATE_CREATE_EXAMPLE],
|
||||
),
|
||||
)
|
||||
def post(self, request, slug, project_id):
|
||||
project = Project.objects.filter(id=project_id, workspace__slug=slug).first()
|
||||
if not project:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Project not found"})
|
||||
|
||||
workspace = Workspace.objects.filter(slug=slug).first()
|
||||
if not workspace:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Workspace not found"})
|
||||
|
||||
project_estimate = self.get_queryset().first()
|
||||
if project_estimate:
|
||||
# return 409 if the project estimate already exists
|
||||
return Response(
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
data={"error": "An estimate already exists for this project", "id": str(project_estimate.id)},
|
||||
)
|
||||
# create the project estimate
|
||||
serializer = self.serializer_class(data=request.data, context={"workspace": workspace, "project": project})
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="get_estimate",
|
||||
summary="Get an estimate",
|
||||
description="Get an estimate for a project",
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate",
|
||||
response=EstimateSerializer,
|
||||
examples=[ESTIMATE_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id):
|
||||
estimate = self.get_queryset().first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
serializer = self.serializer_class(estimate)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="update_estimate",
|
||||
summary="Update an estimate",
|
||||
description="Update an estimate for a project",
|
||||
request=OpenApiRequest(
|
||||
request=EstimateSerializer,
|
||||
examples=[ESTIMATE_UPDATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate",
|
||||
response=EstimateSerializer,
|
||||
examples=[ESTIMATE_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id):
|
||||
ALLOWED_FIELDS = ["name", "description"]
|
||||
estimate = self.get_queryset().first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
|
||||
if not filtered_data:
|
||||
serializer = self.serializer_class(estimate)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
serializer = self.serializer_class(estimate, data=filtered_data, partial=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_docs(
|
||||
operation_id="delete_estimate",
|
||||
summary="Delete an estimate",
|
||||
description="Delete an estimate for a project",
|
||||
responses={
|
||||
204: DELETED_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, slug, project_id):
|
||||
estimate = self.get_queryset().first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
estimate.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class EstimatePointListCreateAPIEndpoint(BaseAPIView):
|
||||
"""List and bulk create estimate points for an estimate."""
|
||||
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = EstimatePoint
|
||||
serializer_class = EstimatePointSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
estimate_id=self.kwargs["estimate_id"],
|
||||
workspace__slug=self.kwargs["slug"],
|
||||
project_id=self.kwargs["project_id"],
|
||||
).select_related("estimate", "workspace", "project")
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="get_estimate_points",
|
||||
summary="Get estimate points",
|
||||
description="Get estimate points for an estimate",
|
||||
parameters=[
|
||||
WORKSPACE_SLUG_PARAMETER,
|
||||
PROJECT_ID_PARAMETER,
|
||||
ESTIMATE_ID_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate points",
|
||||
response=EstimatePointSerializer(many=True),
|
||||
examples=[ESTIMATE_POINT_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.filter(
|
||||
id=estimate_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
estimate_points = self.get_queryset()
|
||||
serializer = self.serializer_class(estimate_points, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="create_estimate_points",
|
||||
summary="Create estimate points",
|
||||
description="Create estimate points for an estimate",
|
||||
request=OpenApiRequest(
|
||||
request=EstimatePointSerializer,
|
||||
examples=[ESTIMATE_POINT_CREATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
description="Estimate points",
|
||||
response=EstimatePointSerializer(many=True),
|
||||
examples=[ESTIMATE_POINT_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def post(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.filter(
|
||||
id=estimate_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
if not estimate:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate not found"})
|
||||
|
||||
estimate_points_data = (
|
||||
request.data if isinstance(request.data, list) else request.data.get("estimate_points", [])
|
||||
)
|
||||
if not estimate_points_data:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={"error": "Estimate points are required"},
|
||||
)
|
||||
|
||||
serializer = self.serializer_class(data=estimate_points_data, many=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
estimate_points = [
|
||||
EstimatePoint(
|
||||
estimate=estimate,
|
||||
workspace=estimate.workspace,
|
||||
project=estimate.project,
|
||||
**item,
|
||||
)
|
||||
for item in serializer.validated_data
|
||||
]
|
||||
created = EstimatePoint.objects.bulk_create(estimate_points)
|
||||
return Response(
|
||||
self.serializer_class(created, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class EstimatePointDetailAPIEndpoint(BaseAPIView):
|
||||
"""Update and delete a single estimate point."""
|
||||
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
model = EstimatePoint
|
||||
serializer_class = EstimatePointSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return self.model.objects.filter(
|
||||
estimate_id=self.kwargs["estimate_id"],
|
||||
workspace__slug=self.kwargs["slug"],
|
||||
project_id=self.kwargs["project_id"],
|
||||
)
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="update_estimate_point",
|
||||
summary="Update an estimate point",
|
||||
description="Update an estimate point for an estimate",
|
||||
request=OpenApiRequest(
|
||||
request=EstimatePointSerializer,
|
||||
examples=[ESTIMATE_POINT_UPDATE_EXAMPLE],
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Estimate point",
|
||||
response=EstimatePointSerializer,
|
||||
examples=[ESTIMATE_POINT_EXAMPLE],
|
||||
),
|
||||
},
|
||||
)
|
||||
def patch(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
|
||||
if not estimate_point:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
|
||||
ALLOWED_FIELDS = ["key", "value", "description"]
|
||||
filtered_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS}
|
||||
if not filtered_data:
|
||||
return Response(self.serializer_class(estimate_point).data, status=status.HTTP_200_OK)
|
||||
serializer = self.serializer_class(estimate_point, data=filtered_data, partial=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@estimate_point_docs(
|
||||
operation_id="delete_estimate_point",
|
||||
summary="Delete an estimate point",
|
||||
description="Delete an estimate point for an estimate",
|
||||
responses={
|
||||
204: DELETED_RESPONSE,
|
||||
},
|
||||
)
|
||||
def delete(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
estimate_point = self.get_queryset().filter(id=estimate_point_id).first()
|
||||
if not estimate_point:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND, data={"error": "Estimate point not found"})
|
||||
estimate_point.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -24,7 +24,6 @@ from django.db.models import (
|
||||
When,
|
||||
Subquery,
|
||||
)
|
||||
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
@@ -46,9 +45,6 @@ from plane.api.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueLinkSerializer,
|
||||
IssueRelationCreateSerializer,
|
||||
IssueRelationResponseSerializer,
|
||||
IssueRelationSerializer,
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
IssueAttachmentUploadSerializer,
|
||||
@@ -57,7 +53,6 @@ from plane.api.serializers import (
|
||||
IssueLinkCreateSerializer,
|
||||
IssueLinkUpdateSerializer,
|
||||
LabelCreateUpdateSerializer,
|
||||
RelatedIssueSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
@@ -71,7 +66,6 @@ from plane.db.models import (
|
||||
FileAsset,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Label,
|
||||
Project,
|
||||
ProjectMember,
|
||||
@@ -79,16 +73,13 @@ from plane.db.models import (
|
||||
Workspace,
|
||||
)
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.app.permissions import ROLE
|
||||
from plane.utils.openapi import (
|
||||
work_item_docs,
|
||||
work_item_relation_docs,
|
||||
label_docs,
|
||||
issue_link_docs,
|
||||
issue_comment_docs,
|
||||
@@ -638,16 +629,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Send the model activity for webhook dispatch
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(issue.id),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
# If the serializer is not valid, respond with 400 bad
|
||||
@@ -696,16 +677,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Send the model activity for webhook dispatch
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
@@ -781,16 +752,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Send the model activity for webhook dispatch
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(pk),
|
||||
requested_data=request.data,
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1128,9 +1089,9 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda issue_links: (
|
||||
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
on_results=lambda issue_links: IssueLinkSerializer(
|
||||
issue_links, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@issue_link_docs(
|
||||
@@ -1235,9 +1196,9 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda issue_links: (
|
||||
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
on_results=lambda issue_links: IssueLinkSerializer(
|
||||
issue_links, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
issue_link = self.get_queryset().get(pk=pk)
|
||||
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
|
||||
@@ -1386,9 +1347,9 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda issue_comments: (
|
||||
IssueCommentSerializer(issue_comments, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
on_results=lambda issue_comments: IssueCommentSerializer(
|
||||
issue_comments, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@issue_comment_docs(
|
||||
@@ -1697,9 +1658,9 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issue_activities),
|
||||
on_results=lambda issue_activity: (
|
||||
IssueActivitySerializer(issue_activity, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
on_results=lambda issue_activity: IssueActivitySerializer(
|
||||
issue_activity, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
@@ -1859,7 +1820,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
name = sanitize_filename(request.data.get("name"))
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = request.data.get("size")
|
||||
external_id = request.data.get("external_id")
|
||||
@@ -2259,284 +2220,3 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)[: int(limit)]
|
||||
|
||||
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueRelationListCreateAPIEndpoint(BaseAPIView):
|
||||
"""Issue Relation List and Create Endpoint"""
|
||||
|
||||
serializer_class = IssueRelationSerializer
|
||||
model = IssueRelation
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
use_read_replica = True
|
||||
|
||||
@work_item_relation_docs(
|
||||
operation_id="list_work_item_relations",
|
||||
summary="List work item relations",
|
||||
description="Retrieve all relationships for a work item including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after relations.", # noqa E501
|
||||
parameters=[
|
||||
ISSUE_ID_PARAMETER,
|
||||
CURSOR_PARAMETER,
|
||||
PER_PAGE_PARAMETER,
|
||||
ORDER_BY_PARAMETER,
|
||||
FIELDS_PARAMETER,
|
||||
EXPAND_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="Work item relations grouped by relation type",
|
||||
response=IssueRelationResponseSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Work Item Relations Response",
|
||||
value={
|
||||
"blocking": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
},
|
||||
],
|
||||
"blocked_by": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440011",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
},
|
||||
],
|
||||
"duplicate": [],
|
||||
"relates_to": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440010",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
},
|
||||
],
|
||||
"start_after": [],
|
||||
"start_before": [
|
||||
{
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440012",
|
||||
"issue_id": "550e8400-e29b-41d4-a716-446655440004",
|
||||
},
|
||||
],
|
||||
"finish_after": [],
|
||||
"finish_before": [],
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
404: ISSUE_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
"""List work item relations
|
||||
|
||||
Retrieve all relationships for a work item organized by relation type.
|
||||
Returns a structured response with relations grouped by type.
|
||||
"""
|
||||
relations = IssueRelation.objects.filter(
|
||||
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
|
||||
workspace__slug=slug,
|
||||
).values(
|
||||
"relation_type",
|
||||
"issue_id",
|
||||
"related_issue_id",
|
||||
issue_project_id=F("issue__project_id"),
|
||||
related_issue_project_id=F("related_issue__project_id"),
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"blocking": [],
|
||||
"blocked_by": [],
|
||||
"duplicate": [],
|
||||
"relates_to": [],
|
||||
"start_after": [],
|
||||
"start_before": [],
|
||||
"finish_after": [],
|
||||
"finish_before": [],
|
||||
}
|
||||
seen_duplicate = set()
|
||||
seen_relates_to = set()
|
||||
|
||||
for rel in relations:
|
||||
rt = rel["relation_type"]
|
||||
if rt == "blocked_by":
|
||||
if str(rel["related_issue_id"]) == str(issue_id):
|
||||
response_data["blocking"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
if str(rel["issue_id"]) == str(issue_id):
|
||||
response_data["blocked_by"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
elif rt == "duplicate":
|
||||
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_duplicate:
|
||||
seen_duplicate.add(rel["related_issue_id"])
|
||||
response_data["duplicate"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_duplicate:
|
||||
seen_duplicate.add(rel["issue_id"])
|
||||
response_data["duplicate"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
elif rt == "relates_to":
|
||||
if str(rel["issue_id"]) == str(issue_id) and rel["related_issue_id"] not in seen_relates_to:
|
||||
seen_relates_to.add(rel["related_issue_id"])
|
||||
response_data["relates_to"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
if str(rel["related_issue_id"]) == str(issue_id) and rel["issue_id"] not in seen_relates_to:
|
||||
seen_relates_to.add(rel["issue_id"])
|
||||
response_data["relates_to"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
elif rt == "start_before":
|
||||
if str(rel["related_issue_id"]) == str(issue_id):
|
||||
response_data["start_after"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
if str(rel["issue_id"]) == str(issue_id):
|
||||
response_data["start_before"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
elif rt == "finish_before":
|
||||
if str(rel["related_issue_id"]) == str(issue_id):
|
||||
response_data["finish_after"].append(
|
||||
{"project_id": str(rel["issue_project_id"]), "issue_id": str(rel["issue_id"])}
|
||||
)
|
||||
if str(rel["issue_id"]) == str(issue_id):
|
||||
response_data["finish_before"].append(
|
||||
{"project_id": str(rel["related_issue_project_id"]), "issue_id": str(rel["related_issue_id"])}
|
||||
)
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
@work_item_relation_docs(
|
||||
operation_id="create_work_item_relation",
|
||||
summary="Create work item relation",
|
||||
description="Create relationships between work items. Supports various relation types including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after.", # noqa E501
|
||||
parameters=[
|
||||
ISSUE_ID_PARAMETER,
|
||||
],
|
||||
request=OpenApiRequest(
|
||||
request=IssueRelationCreateSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Create blocking relation",
|
||||
value={
|
||||
"relation_type": "blocking",
|
||||
"issues": [
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
],
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
description="Work item relations created successfully",
|
||||
response=IssueRelationSerializer(many=True),
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Relations created",
|
||||
value=[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Fix authentication bug",
|
||||
"sequence_id": 42,
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"relation_type": "blocked_by",
|
||||
"state_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"priority": "high",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"updated_at": "2024-01-15T10:00:00Z",
|
||||
"created_by": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"updated_by": "550e8400-e29b-41d4-a716-446655440004",
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
400: INVALID_REQUEST_RESPONSE,
|
||||
404: ISSUE_NOT_FOUND_RESPONSE,
|
||||
},
|
||||
)
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
"""Create work item relation
|
||||
|
||||
Create relationships between work items with specified relation type.
|
||||
Automatically tracks relation creation activity.
|
||||
"""
|
||||
# Validate request data using serializer
|
||||
serializer = IssueRelationCreateSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
relation_type = serializer.validated_data["relation_type"]
|
||||
issues = serializer.validated_data["issues"]
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
actual_relation = get_actual_relation(relation_type)
|
||||
is_reverse = relation_type in ["blocking", "start_after", "finish_after"]
|
||||
|
||||
IssueRelation.objects.bulk_create(
|
||||
[
|
||||
IssueRelation(
|
||||
issue_id=(issue if is_reverse else issue_id),
|
||||
related_issue_id=(issue_id if is_reverse else issue),
|
||||
relation_type=actual_relation,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue_relation.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
# Re-fetch with select_related to avoid N+1 queries in serializers.
|
||||
# bulk_create with ignore_conflicts=True may not return PKs,
|
||||
# so query by the issue/related_issue pairs and relation type.
|
||||
if is_reverse:
|
||||
refetch_filter = Q(
|
||||
issue_id__in=issues,
|
||||
related_issue_id=issue_id,
|
||||
relation_type=actual_relation,
|
||||
)
|
||||
else:
|
||||
refetch_filter = Q(
|
||||
issue_id=issue_id,
|
||||
related_issue_id__in=issues,
|
||||
relation_type=actual_relation,
|
||||
)
|
||||
|
||||
refetched_relations = IssueRelation.objects.filter(
|
||||
refetch_filter,
|
||||
workspace__slug=slug,
|
||||
).select_related(
|
||||
"issue__state",
|
||||
"related_issue__state",
|
||||
)
|
||||
|
||||
serializer_class = RelatedIssueSerializer if is_reverse else IssueRelationSerializer
|
||||
return Response(
|
||||
serializer_class(refetched_relations, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import IntegrityError
|
||||
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,7 +38,6 @@ 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 (
|
||||
@@ -224,72 +223,48 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
|
||||
|
||||
if serializer.is_valid():
|
||||
with transaction.atomic():
|
||||
serializer.save()
|
||||
serializer.save()
|
||||
|
||||
# Add the creator as Administrator of the project.
|
||||
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, 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
|
||||
]
|
||||
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,
|
||||
)
|
||||
|
||||
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),
|
||||
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
|
||||
]
|
||||
)
|
||||
|
||||
transaction.on_commit(_dispatch_model_activity, robust=True)
|
||||
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),
|
||||
)
|
||||
|
||||
serializer = ProjectSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -300,17 +275,6 @@ 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:
|
||||
@@ -318,16 +282,6 @@ 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,17 +22,6 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Check for creator if required
|
||||
if creator and model:
|
||||
# check if the user is part of the workspace or not
|
||||
if not WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists()
|
||||
if obj:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
@@ -22,7 +22,6 @@ class APITokenSerializer(BaseSerializer):
|
||||
"is_active",
|
||||
"last_used",
|
||||
"user_type",
|
||||
"allowed_rate_limit",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
||||
@@ -3,66 +3,90 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from .base import DynamicBaseSerializer
|
||||
from plane.db.models import Webhook, WebhookLog
|
||||
from plane.db.models.webhook import validate_domain, validate_schema
|
||||
from plane.utils.ip_address import validate_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookSerializer(DynamicBaseSerializer):
|
||||
url = serializers.URLField(validators=[validate_schema, validate_domain])
|
||||
|
||||
def _validate_webhook_url(self, url):
|
||||
"""Validate a webhook URL against SSRF and disallowed domain rules."""
|
||||
def create(self, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
try:
|
||||
validate_url(
|
||||
url,
|
||||
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
|
||||
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Webhook URL validation failed for %s: %s", url, e)
|
||||
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
|
||||
|
||||
hostname = (urlparse(url).hostname or "").rstrip(".").lower()
|
||||
if not ip_addresses:
|
||||
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
|
||||
|
||||
# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
|
||||
# disallowed-domain check — they're already trusted for SSRF, so
|
||||
# the loop-back guard would only get in the way of legitimate
|
||||
# sibling services that share a parent domain with Plane.
|
||||
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
|
||||
return
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0].rstrip(".").lower()
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
|
||||
|
||||
def create(self, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
self._validate_webhook_url(url)
|
||||
return Webhook.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
if url:
|
||||
self._validate_webhook_url(url)
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
|
||||
|
||||
if not ip_addresses:
|
||||
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.urls import path
|
||||
from plane.app.views import ApiTokenEndpoint
|
||||
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
@@ -17,5 +17,10 @@ urlpatterns = [
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens-details",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
ServiceApiTokenEndpoint.as_view(),
|
||||
name="service-api-tokens",
|
||||
),
|
||||
## End API Tokens
|
||||
]
|
||||
|
||||
@@ -165,7 +165,7 @@ from .module.issue import ModuleIssueViewSet
|
||||
|
||||
from .module.archive import ModuleArchiveUnarchiveEndpoint
|
||||
|
||||
from .api import ApiTokenEndpoint
|
||||
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
|
||||
from .page.base import (
|
||||
PageViewSet,
|
||||
|
||||
@@ -29,7 +29,7 @@ from plane.db.models import (
|
||||
Module,
|
||||
)
|
||||
|
||||
from plane.utils.analytics_plot import build_graph_plot, VALID_ANALYTICS_FIELDS, VALID_YAXIS
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
|
||||
@@ -41,15 +41,32 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
y_axis = request.GET.get("y_axis", False)
|
||||
segment = request.GET.get("segment", False)
|
||||
|
||||
valid_xaxis_segment = [
|
||||
"state_id",
|
||||
"state__group",
|
||||
"labels__id",
|
||||
"assignees__id",
|
||||
"estimate_point__value",
|
||||
"issue_cycle__cycle_id",
|
||||
"issue_module__module_id",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"created_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
valid_yaxis = ["issue_count", "estimate"]
|
||||
|
||||
# Check for x-axis and y-axis as thery are required parameters
|
||||
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
|
||||
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If segment is present it cannot be same as x-axis
|
||||
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
|
||||
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
|
||||
return Response(
|
||||
{"error": "Both segment and x axis cannot be same and segment should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -197,20 +214,13 @@ class SavedAnalyticEndpoint(BaseAPIView):
|
||||
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||
|
||||
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
|
||||
if not x_axis or not y_axis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
|
||||
{"error": "x-axis and y-axis dimensions are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
segment = request.GET.get("segment", False)
|
||||
|
||||
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
|
||||
return Response(
|
||||
{"error": "Both segment and x axis cannot be same and segment should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
|
||||
total_issues = queryset.count()
|
||||
return Response(
|
||||
@@ -226,15 +236,32 @@ class ExportAnalyticsEndpoint(BaseAPIView):
|
||||
y_axis = request.data.get("y_axis", False)
|
||||
segment = request.data.get("segment", False)
|
||||
|
||||
valid_xaxis_segment = [
|
||||
"state_id",
|
||||
"state__group",
|
||||
"labels__id",
|
||||
"assignees__id",
|
||||
"estimate_point",
|
||||
"issue_cycle__cycle_id",
|
||||
"issue_module__module_id",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"created_at",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
valid_yaxis = ["issue_count", "estimate"]
|
||||
|
||||
# Check for x-axis and y-axis as thery are required parameters
|
||||
if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
|
||||
if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
|
||||
return Response(
|
||||
{"error": "x-axis and y-axis dimensions are required and the values should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# If segment is present it cannot be same as x-axis
|
||||
if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
|
||||
if segment and (segment not in valid_xaxis_segment or x_axis == segment):
|
||||
return Response(
|
||||
{"error": "Both segment and x axis cannot be same and segment should be valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -13,8 +13,9 @@ from rest_framework import status
|
||||
|
||||
# Module import
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models import APIToken
|
||||
from plane.db.models import APIToken, Workspace
|
||||
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
@@ -44,7 +45,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, is_service=False)
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -54,9 +55,34 @@ 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, is_service=False)
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()
|
||||
|
||||
if api_token:
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
# Check the user type
|
||||
user_type = 1 if request.user.is_bot else 0
|
||||
|
||||
api_token = APIToken.objects.create(
|
||||
label=str(uuid4().hex),
|
||||
description="Service Token",
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
is_service=True,
|
||||
)
|
||||
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -18,11 +18,10 @@ from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.db.models import FileAsset, Workspace, Project, User, WorkspaceMember
|
||||
from plane.db.models import FileAsset, Workspace, Project, User
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.cache import invalidate_cache_directly
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.throttles.asset import AssetRateThrottle
|
||||
|
||||
@@ -109,7 +108,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
|
||||
def post(self, request):
|
||||
# get the asset key
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", False)
|
||||
@@ -312,9 +311,8 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
else:
|
||||
return
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type")
|
||||
@@ -378,7 +376,6 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
@@ -400,7 +397,6 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def delete(self, request, slug, asset_id):
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
asset.is_deleted = True
|
||||
@@ -410,7 +406,6 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
@@ -516,7 +511,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id):
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", "")
|
||||
@@ -757,22 +752,12 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
# Scope the source asset lookup to workspaces the caller is a member of
|
||||
user_workspace_ids = WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).values_list("workspace_id", flat=True)
|
||||
original_asset = FileAsset.objects.filter(
|
||||
id=asset_id,
|
||||
is_uploaded=True,
|
||||
workspace_id__in=user_workspace_ids,
|
||||
).first()
|
||||
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
|
||||
|
||||
if not original_asset:
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
sanitized_name = sanitize_filename(original_asset.attributes.get("name")) or "unnamed"
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{sanitized_name}"
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
|
||||
@@ -120,7 +120,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi
|
||||
return response
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
return response
|
||||
return exc
|
||||
|
||||
@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 response
|
||||
return exc
|
||||
|
||||
@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, workspace__slug=slug, project_id=project_id)
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
if request.data.get("estimate"):
|
||||
estimate.name = request.data.get("estimate").get("name", estimate.name)
|
||||
|
||||
@@ -24,7 +24,6 @@ from plane.db.models import FileAsset, Workspace
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
|
||||
@@ -65,10 +64,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
).first()
|
||||
if not issue_attachment:
|
||||
return Response(
|
||||
{"error": "Issue attachment not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
@@ -98,7 +94,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ 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:
|
||||
@@ -158,7 +157,7 @@ class IssueListEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
if self.fields or self.expand:
|
||||
issues = IssueSerializer(issue_queryset, many=True, fields=self.fields, expand=self.expand).data
|
||||
issues = IssueSerializer(queryset, many=True, fields=self.fields, expand=self.expand).data
|
||||
else:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
@@ -1119,7 +1118,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
|
||||
epoch = int(timezone.now().timestamp())
|
||||
|
||||
# Fetch all relevant issues in a single query
|
||||
issues = list(Issue.objects.filter(id__in=issue_ids, workspace__slug=slug, project_id=project_id))
|
||||
issues = list(Issue.objects.filter(id__in=issue_ids))
|
||||
issues_dict = {str(issue.id): issue for issue in issues}
|
||||
issues_to_update = []
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery, Count, IntegerField
|
||||
from django.db.models import OuterRef, Func, F, Q, Value, UUIDField, Subquery
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
@@ -22,7 +22,7 @@ from rest_framework import status
|
||||
from .. import BaseAPIView
|
||||
from plane.app.serializers import IssueSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue, IssueLabel, IssueAssignee, ModuleIssue
|
||||
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from collections import defaultdict
|
||||
@@ -37,97 +37,70 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
sub_issues = (
|
||||
Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=Coalesce(
|
||||
Subquery(
|
||||
IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.values("issue")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
0,
|
||||
)
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=Coalesce(
|
||||
Subquery(
|
||||
FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
0,
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Coalesce(
|
||||
Subquery(
|
||||
Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.values("parent")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count"),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
0,
|
||||
)
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
Subquery(
|
||||
IssueLabel.objects.filter(issue_id=OuterRef("id"), deleted_at__isnull=True)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("label_id", distinct=True))
|
||||
.values("arr"),
|
||||
output_field=ArrayField(UUIDField()),
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
Subquery(
|
||||
IssueAssignee.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
assignee__member_project__is_active=True,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("assignee_id", distinct=True))
|
||||
.values("arr"),
|
||||
output_field=ArrayField(UUIDField()),
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
Subquery(
|
||||
ModuleIssue.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
module__archived_at__isnull=True,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.order_by()
|
||||
.values("issue_id")
|
||||
.annotate(arr=ArrayAgg("module_id", distinct=True))
|
||||
.values("arr"),
|
||||
output_field=ArrayField(UUIDField()),
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(state_group=F("state__group"))
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
# Ordering
|
||||
@@ -137,42 +110,38 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
if order_by_param:
|
||||
sub_issues, order_by_param = order_issue_queryset(sub_issues, order_by_param)
|
||||
|
||||
sub_issues = list(
|
||||
sub_issues.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"state_group",
|
||||
)
|
||||
)
|
||||
|
||||
# create's a dict with state group name with their respective issue id's
|
||||
result = defaultdict(list)
|
||||
for sub_issue in sub_issues:
|
||||
result[sub_issue["state_group"]].append(str(sub_issue["id"]))
|
||||
result[sub_issue.state_group].append(str(sub_issue.id))
|
||||
|
||||
sub_issues = sub_issues.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
sub_issues = user_timezone_converter(sub_issues, datetime_fields, request.user.user_timezone)
|
||||
# Grouping
|
||||
|
||||
@@ -332,7 +332,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project = Project.objects.get(pk=pk, workspace__slug=slug)
|
||||
project = Project.objects.get(pk=pk)
|
||||
intake_view = request.data.get("inbox_view", project.intake_view)
|
||||
current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder)
|
||||
if project.archived_at:
|
||||
|
||||
@@ -206,15 +206,11 @@ 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 target's workspace role (used to cap the new project role)
|
||||
target_workspace_role = WorkspaceMember.objects.get(
|
||||
# Fetch the workspace role of the project member
|
||||
workspace_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=project_member.member, is_active=True
|
||||
).role
|
||||
# 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
|
||||
is_workspace_admin = 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:
|
||||
@@ -230,36 +226,21 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if "role" in request.data:
|
||||
# Only Admins can modify roles
|
||||
if requested_project_member.role < ROLE.ADMIN.value and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You do not have permission to update roles"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]:
|
||||
return Response(
|
||||
{"error": "You cannot add a user with role higher than the workspace role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Cannot modify a member whose role is equal to or higher than your own
|
||||
if project_member.role >= requested_project_member.role and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You cannot update the role of a member with a role equal to or higher than your own"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
new_role = int(request.data.get("role"))
|
||||
|
||||
# Cannot assign a role equal to or higher than your own
|
||||
if new_role >= requested_project_member.role and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You cannot assign a role equal to or higher than your own"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Cannot assign a role higher than the target's workspace role
|
||||
if 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,
|
||||
)
|
||||
if (
|
||||
"role" in request.data
|
||||
and int(request.data.get("role", project_member.role)) > requested_project_member.role
|
||||
and not is_workspace_admin
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot update a role that is higher than your own role"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class WebhookEndpoint(BaseAPIView):
|
||||
serializer = WebhookSerializer(
|
||||
webhook,
|
||||
data=request.data,
|
||||
context={"request": request},
|
||||
context={request: request},
|
||||
partial=True,
|
||||
fields=(
|
||||
"id",
|
||||
|
||||
@@ -279,16 +279,11 @@ 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,62 +146,48 @@ class Adapter:
|
||||
|
||||
try:
|
||||
headers = self.get_avatar_download_headers()
|
||||
# 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()
|
||||
# Download the avatar image
|
||||
response = requests.get(avatar_url, timeout=10, headers=headers)
|
||||
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:
|
||||
# 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:
|
||||
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:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
file_size = len(content)
|
||||
finally:
|
||||
response.close()
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
file_size = len(content)
|
||||
|
||||
# Generate unique filename
|
||||
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
|
||||
|
||||
storage = S3Storage(request=self.request)
|
||||
|
||||
# Create file-like object from the size-bounded buffer
|
||||
file_obj = BytesIO(content)
|
||||
# Create file-like object
|
||||
file_obj = BytesIO(response.content)
|
||||
file_obj.seek(0)
|
||||
|
||||
# Upload using boto3 directly
|
||||
|
||||
@@ -22,27 +22,6 @@ 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(
|
||||
[
|
||||
@@ -113,9 +92,6 @@ 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):
|
||||
@@ -138,52 +114,12 @@ class MagicCodeProvider(CredentialAdapter):
|
||||
},
|
||||
}
|
||||
)
|
||||
# Delete the token and its counter from redis on success.
|
||||
# Delete the token from redis if the code match is successful
|
||||
ri.delete(self.key)
|
||||
ri.delete(self._verify_attempts_key(self.key))
|
||||
return
|
||||
else:
|
||||
email = str(self.key).replace("magic_", "", 1)
|
||||
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:
|
||||
if User.objects.filter(email=email).exists():
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_MAGIC_CODE_SIGN_IN"],
|
||||
error_message="INVALID_MAGIC_CODE_SIGN_IN",
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
# 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
|
||||
@@ -18,9 +15,7 @@ from plane.authentication.adapter.error import (
|
||||
|
||||
|
||||
class AuthenticationThrottle(AnonRateThrottle):
|
||||
# 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")
|
||||
rate = "30/minute"
|
||||
scope = "authentication"
|
||||
|
||||
def throttle_failure_view(self, request, *args, **kwargs):
|
||||
@@ -33,22 +28,6 @@ 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,10 +26,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.authentication.rate_limit import (
|
||||
AuthenticationThrottle,
|
||||
authentication_throttle_allows,
|
||||
)
|
||||
from plane.authentication.rate_limit import AuthenticationThrottle
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
@@ -68,18 +65,6 @@ 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"],
|
||||
@@ -151,18 +136,6 @@ 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,18 +25,12 @@ 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()
|
||||
@@ -66,18 +60,6 @@ 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"],
|
||||
@@ -137,18 +119,6 @@ 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,16 +5,19 @@
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Callable, Iterable
|
||||
from typing import List, Dict, Any, Callable, Optional
|
||||
import os
|
||||
|
||||
# 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 (
|
||||
@@ -24,6 +27,7 @@ from plane.db.models import (
|
||||
IssueDescriptionVersion,
|
||||
WebhookLog,
|
||||
)
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -31,75 +35,285 @@ 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[[], Iterable],
|
||||
queryset_func: Callable,
|
||||
transform_func: Callable[[Dict], Dict],
|
||||
model,
|
||||
task_name: str,
|
||||
collection_name: str,
|
||||
):
|
||||
"""
|
||||
Batch-delete expired rows for the given model from PostgreSQL.
|
||||
Generic function to process cleanup tasks.
|
||||
|
||||
Args:
|
||||
queryset_func: Callable returning an iterable of primary keys to delete.
|
||||
model: Django model class.
|
||||
task_name: Name of the task for logging.
|
||||
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
|
||||
"""
|
||||
logger.info(f"Starting {task_name} cleanup task")
|
||||
|
||||
total_deleted = 0
|
||||
# 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_batches = 0
|
||||
batch: list = []
|
||||
|
||||
def flush(ids: list) -> None:
|
||||
nonlocal total_deleted, total_batches
|
||||
if not ids:
|
||||
return
|
||||
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:
|
||||
total_batches += 1
|
||||
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)
|
||||
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)
|
||||
|
||||
logger.info(
|
||||
f"{task_name} cleanup task completed",
|
||||
extra={"total_records_deleted": total_deleted, "total_batches": total_batches},
|
||||
extra={
|
||||
"total_records_processed": total_processed,
|
||||
"total_batches": total_batches,
|
||||
"mongo_available": mongo_available,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Queryset functions for each cleanup task — each yields primary keys to delete
|
||||
# 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
|
||||
def get_api_logs_queryset():
|
||||
"""Get API activity logs older than the API retention window."""
|
||||
cutoff_time = timezone.now() - timedelta(days=settings.API_ACTIVITY_LOG_RETENTION_DAYS)
|
||||
"""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)
|
||||
logger.info(f"API logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values_list("id", flat=True)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"token_identifier",
|
||||
"path",
|
||||
"method",
|
||||
"query_params",
|
||||
"headers",
|
||||
"body",
|
||||
"response_code",
|
||||
"response_body",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_email_logs_queryset():
|
||||
"""Get email logs older than the email retention window."""
|
||||
cutoff_time = timezone.now() - timedelta(days=settings.EMAIL_LOG_RETENTION_DAYS)
|
||||
"""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)
|
||||
logger.info(f"Email logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
|
||||
.values_list("id", flat=True)
|
||||
.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",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
@@ -120,7 +334,22 @@ def get_page_versions_queryset():
|
||||
|
||||
return (
|
||||
PageVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values_list("id", flat=True)
|
||||
.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",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
@@ -141,20 +370,52 @@ def get_issue_description_versions_queryset():
|
||||
|
||||
return (
|
||||
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values_list("id", flat=True)
|
||||
.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",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_webhook_logs_queryset():
|
||||
"""Get webhook logs older than the webhook retention window."""
|
||||
cutoff_time = timezone.now() - timedelta(days=settings.WEBHOOK_LOG_RETENTION_DAYS)
|
||||
"""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)
|
||||
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")
|
||||
.values_list("id", flat=True)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
.iterator(chunk_size=100)
|
||||
)
|
||||
|
||||
|
||||
@@ -163,8 +424,10 @@ 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",
|
||||
)
|
||||
|
||||
|
||||
@@ -173,8 +436,10 @@ 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",
|
||||
)
|
||||
|
||||
|
||||
@@ -183,8 +448,10 @@ 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",
|
||||
)
|
||||
|
||||
|
||||
@@ -193,16 +460,20 @@ 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,12 +4,14 @@
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from typing import Optional, 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
|
||||
|
||||
@@ -17,9 +19,66 @@ 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:
|
||||
"""
|
||||
Persist an external API request log to PostgreSQL.
|
||||
Fallback to logging to PostgreSQL if MongoDB is unavailable.
|
||||
"""
|
||||
try:
|
||||
APIActivityLog.objects.create(**log_data)
|
||||
@@ -30,12 +89,12 @@ def log_to_postgres(log_data: Dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_logs(log_data: Dict[str, Any], **_: Any) -> None:
|
||||
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
|
||||
"""
|
||||
Persist external API request logs to PostgreSQL.
|
||||
|
||||
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)
|
||||
if MongoConnection.is_configured():
|
||||
log_to_mongo(mongo_log)
|
||||
else:
|
||||
log_to_postgres(log_data)
|
||||
|
||||
@@ -52,7 +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.url_security import pinned_fetch
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
@@ -101,6 +101,9 @@ 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),
|
||||
@@ -114,12 +117,27 @@ def save_webhook_log(
|
||||
"retry_count": retry_count,
|
||||
}
|
||||
|
||||
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}")
|
||||
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}")
|
||||
|
||||
|
||||
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
|
||||
@@ -307,21 +325,8 @@ def webhook_send_task(
|
||||
return
|
||||
|
||||
try:
|
||||
# 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(
|
||||
@@ -364,25 +369,6 @@ 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
|
||||
|
||||
@@ -13,12 +13,10 @@ from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import base64
|
||||
import ipaddress
|
||||
from typing import Dict, Any, Tuple
|
||||
from typing import Dict, Any
|
||||
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")
|
||||
|
||||
@@ -38,70 +36,36 @@ 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, UnicodeError):
|
||||
# UnicodeError covers IDNA failures raised before the address lookup.
|
||||
except socket.gaierror:
|
||||
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. The
|
||||
# actual fetch is pinned to the validated IP (see safe_get), so this acts
|
||||
# as an early, fail-closed pre-filter.
|
||||
# Check every resolved IP against blocked ranges to prevent SSRF
|
||||
for addr in addr_info:
|
||||
ip = ipaddress.ip_address(addr[4][0].split("%")[0])
|
||||
if is_blocked_ip(ip):
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
|
||||
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def safe_get(
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 1,
|
||||
) -> Tuple[requests.Response, str]:
|
||||
"""
|
||||
Perform a GET request that 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
|
||||
headers: Optional request headers
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
A tuple of (final Response object, final URL after redirects)
|
||||
|
||||
Raises:
|
||||
ValueError: If any URL in the redirect chain points to a private IP
|
||||
requests.RequestException: On network errors (incl. TooManyRedirects)
|
||||
"""
|
||||
return pinned_fetch_following_redirects(
|
||||
"GET",
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
max_redirects=MAX_REDIRECTS,
|
||||
)
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
@@ -122,8 +86,26 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
title = None
|
||||
final_url = url
|
||||
|
||||
validate_url_ip(final_url)
|
||||
|
||||
try:
|
||||
response, final_url = safe_get(url, headers=headers)
|
||||
# Manually follow redirects to validate each URL before requesting
|
||||
redirect_count = 0
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
while response.is_redirect and redirect_count < MAX_REDIRECTS:
|
||||
redirect_url = response.headers.get("Location")
|
||||
if not redirect_url:
|
||||
break
|
||||
# Resolve relative redirects against current URL
|
||||
final_url = urljoin(final_url, redirect_url)
|
||||
# Validate the redirect target BEFORE making the request
|
||||
validate_url_ip(final_url)
|
||||
redirect_count += 1
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
logger.warning(f"Too many redirects for URL: {url}")
|
||||
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
title_tag = soup.find("title")
|
||||
@@ -131,10 +113,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"URL validation failed: {str(e)}")
|
||||
|
||||
# Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
|
||||
# Fetch and encode favicon using final URL (after redirects)
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
|
||||
|
||||
# Prepare result
|
||||
@@ -189,13 +169,14 @@ 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 (pinned to the validated IP).
|
||||
# Check if fallback exists
|
||||
try:
|
||||
response = pinned_fetch("HEAD", fallback_url, timeout=2)
|
||||
validate_url_ip(fallback_url)
|
||||
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
|
||||
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
except requests.RequestException as e:
|
||||
log_exception(e, warning=True)
|
||||
return None
|
||||
|
||||
@@ -223,7 +204,9 @@ def fetch_and_encode_favicon(
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
response, _ = safe_get(favicon_url, headers=headers)
|
||||
validate_url_ip(favicon_url)
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
# Python imports
|
||||
import os
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
# Third party imports
|
||||
from celery import Celery
|
||||
from pythonjsonlogger.json import JsonFormatter
|
||||
from pythonjsonlogger.jsonlogger import JsonFormatter
|
||||
from celery.signals import after_setup_logger, after_setup_task_logger
|
||||
from celery.schedules import crontab, schedule
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Module imports
|
||||
from plane.settings.redis import redis_instance
|
||||
@@ -21,20 +20,6 @@ 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
|
||||
@@ -47,9 +32,9 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
|
||||
"schedule": crontab(minute="*/5"), # Every 5 minutes
|
||||
},
|
||||
"push-instance-metrics": {
|
||||
"task": "plane.license.bgtasks.telemetry_metrics.push_instance_metrics",
|
||||
"schedule": schedule(run_every=timedelta(minutes=METRICS_PUSH_INTERVAL_MINUTES)),
|
||||
"run-every-6-hours-for-instance-trace": {
|
||||
"task": "plane.license.bgtasks.tracer.instance_traces",
|
||||
"schedule": crontab(hour="*/6", minute=0), # Every 6 hours
|
||||
},
|
||||
# Occurs once every day
|
||||
"check-every-day-to-delete-hard-delete": {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-26 14:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0120_issueview_archived_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='estimate',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('categories', 'Categories'), ('points', 'Points')], default='categories', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -11,13 +11,10 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
# Module import
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = sanitize_filename(filename) or uuid4().hex
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
||||
@@ -10,15 +10,11 @@ from django.db.models import Q
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
class EstimateType(models.TextChoices):
|
||||
CATEGORIES = "categories", "Categories"
|
||||
POINTS = "points", "Points"
|
||||
|
||||
|
||||
class Estimate(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Estimate Description", blank=True)
|
||||
type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES)
|
||||
type = models.CharField(max_length=255, default="categories")
|
||||
last_used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -17,12 +17,12 @@ 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, ChangeTrackerMixin
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
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
|
||||
|
||||
|
||||
@@ -101,9 +101,7 @@ class IssueManager(SoftDeletionManager):
|
||||
)
|
||||
|
||||
|
||||
class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
TRACKED_FIELDS = ["state_id"]
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
@@ -178,8 +176,30 @@ class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self._ensure_default_state()
|
||||
kwargs = self._sync_completed_at(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
|
||||
|
||||
if self._state.adding:
|
||||
with transaction.atomic():
|
||||
@@ -225,35 +245,6 @@ class Issue(ChangeTrackerMixin, 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)
|
||||
@@ -385,7 +376,6 @@ class IssueLink(ProjectBaseModel):
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
filename = sanitize_filename(filename) or uuid4().hex
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
return response
|
||||
return exc
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
|
||||
@@ -45,8 +45,7 @@ class InstanceConfigurationEndpoint(BaseAPIView):
|
||||
|
||||
bulk_configurations = []
|
||||
for configuration in configurations:
|
||||
raw_value = request.data.get(configuration.key, configuration.value)
|
||||
value = "" if raw_value is None else str(raw_value).strip()
|
||||
value = request.data.get(configuration.key, configuration.value)
|
||||
if configuration.is_encrypted:
|
||||
configuration.value = encrypt_data(value)
|
||||
else:
|
||||
|
||||
@@ -63,6 +63,8 @@ class InstanceEndpoint(BaseAPIView):
|
||||
POSTHOG_HOST,
|
||||
UNSPLASH_ACCESS_KEY,
|
||||
LLM_API_KEY,
|
||||
IS_INTERCOM_ENABLED,
|
||||
INTERCOM_APP_ID,
|
||||
) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
@@ -122,6 +124,15 @@ class InstanceEndpoint(BaseAPIView):
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", ""),
|
||||
},
|
||||
# Intercom settings
|
||||
{
|
||||
"key": "IS_INTERCOM_ENABLED",
|
||||
"default": os.environ.get("IS_INTERCOM_ENABLED", "1"),
|
||||
},
|
||||
{
|
||||
"key": "INTERCOM_APP_ID",
|
||||
"default": os.environ.get("INTERCOM_APP_ID", ""),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
@@ -158,6 +169,10 @@ class InstanceEndpoint(BaseAPIView):
|
||||
# is smtp configured
|
||||
data["is_smtp_configured"] = bool(EMAIL_HOST)
|
||||
|
||||
# Intercom settings
|
||||
data["is_intercom_enabled"] = IS_INTERCOM_ENABLED == "1"
|
||||
data["intercom_app_id"] = INTERCOM_APP_ID
|
||||
|
||||
# Base URL
|
||||
data["admin_base_url"] = settings.ADMIN_BASE_URL
|
||||
data["space_base_url"] = settings.SPACE_BASE_URL
|
||||
|
||||
@@ -1,381 +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 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}")
|
||||
@@ -0,0 +1,105 @@
|
||||
# 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.telemetry_metrics import push_instance_metrics
|
||||
from plane.license.bgtasks.tracer import instance_traces
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -86,7 +86,7 @@ class Command(BaseCommand):
|
||||
instance.edition = InstanceEdition.PLANE_COMMUNITY.value
|
||||
instance.save()
|
||||
|
||||
# Push instance metrics on registration
|
||||
push_instance_metrics.delay()
|
||||
# Call the instance traces task
|
||||
instance_traces.delay()
|
||||
|
||||
return
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
# 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
|
||||
@@ -79,7 +77,7 @@ class RequestLoggerMiddleware:
|
||||
|
||||
class APITokenLogMiddleware:
|
||||
"""
|
||||
Middleware to log External API requests to PostgreSQL.
|
||||
Middleware to log External API requests to MongoDB or PostgreSQL.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
@@ -113,20 +111,6 @@ 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)
|
||||
@@ -137,25 +121,32 @@ class APITokenLogMiddleware:
|
||||
|
||||
try:
|
||||
log_data = {
|
||||
# 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(),
|
||||
"token_identifier": api_key,
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"query_params": request.META.get("QUERY_STRING", ""),
|
||||
"headers": self._redacted_headers(request),
|
||||
"headers": str(request.headers),
|
||||
"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)
|
||||
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"""Global Settings"""
|
||||
|
||||
# Python imports
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urljoin
|
||||
@@ -34,44 +32,6 @@ DEBUG = int(os.environ.get("DEBUG", "0"))
|
||||
# Self-hosted mode
|
||||
IS_SELF_MANAGED = True
|
||||
|
||||
# Webhook IP allowlist — comma-separated IPs or CIDR ranges that are allowed as
|
||||
# webhook targets even if they resolve to private networks.
|
||||
# Example: "10.0.0.0/8,192.168.1.0/24,172.16.0.5"
|
||||
_webhook_allowed_ips_raw = os.environ.get("WEBHOOK_ALLOWED_IPS", "")
|
||||
WEBHOOK_ALLOWED_IPS = []
|
||||
_logger = logging.getLogger("plane")
|
||||
for _cidr in _webhook_allowed_ips_raw.split(","):
|
||||
_cidr = _cidr.strip()
|
||||
if not _cidr:
|
||||
continue
|
||||
try:
|
||||
WEBHOOK_ALLOWED_IPS.append(ipaddress.ip_network(_cidr, strict=False))
|
||||
except ValueError:
|
||||
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
|
||||
|
||||
# Webhook hostname allowlist — comma-separated hostnames that bypass the
|
||||
# private-IP SSRF check. Useful for trusted internal services whose IPs are
|
||||
# dynamic in containerised deployments (e.g. docker-compose service DNS,
|
||||
# kubernetes service hostnames).
|
||||
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
|
||||
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
|
||||
WEBHOOK_ALLOWED_HOSTS = [
|
||||
_host.strip().rstrip(".").lower()
|
||||
for _host in _webhook_allowed_hosts_raw.split(",")
|
||||
if _host.strip()
|
||||
]
|
||||
|
||||
# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
|
||||
# these domains or any of their subdomains are rejected (the request host is
|
||||
# always appended at validation time as a loop-back guard). Empty by default
|
||||
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
|
||||
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
|
||||
WEBHOOK_DISALLOWED_DOMAINS = [
|
||||
_d.strip().rstrip(".").lower()
|
||||
for _d in _webhook_disallowed_domains_raw.split(",")
|
||||
if _d.strip()
|
||||
]
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
|
||||
|
||||
@@ -132,9 +92,6 @@ 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
|
||||
|
||||
@@ -267,6 +224,7 @@ MEDIA_URL = "/media/"
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = "en-us"
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
|
||||
# Timezones
|
||||
USE_TZ = True
|
||||
@@ -324,7 +282,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.cleanup_task",
|
||||
"plane.license.bgtasks.telemetry_metrics",
|
||||
"plane.license.bgtasks.tracer",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
# issue version tasks
|
||||
@@ -405,34 +363,6 @@ 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", "")
|
||||
|
||||
@@ -535,3 +465,7 @@ 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.json.JsonFormatter",
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
|
||||
},
|
||||
},
|
||||
@@ -75,6 +75,11 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.mongo": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.authentication": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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.json.JsonFormatter",
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
|
||||
},
|
||||
},
|
||||
@@ -85,6 +85,11 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.mongo": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.authentication": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
|
||||
@@ -18,7 +18,6 @@ from rest_framework.response import Response
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.db.models import DeployBoard, FileAsset
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.path_validator import sanitize_filename
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
@@ -74,7 +73,7 @@ class EntityAssetEndpoint(BaseAPIView):
|
||||
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Get the asset
|
||||
name = sanitize_filename(request.data.get("name")) or "unnamed"
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", "image/jpeg")
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
entity_type = request.data.get("entity_type", "")
|
||||
|
||||
@@ -114,7 +114,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
return response
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
return response
|
||||
return exc
|
||||
|
||||
@property
|
||||
def workspace_slug(self):
|
||||
@@ -197,7 +197,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
|
||||
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
return response
|
||||
return exc
|
||||
|
||||
@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)
|
||||
- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB)
|
||||
- `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`, and `mock_celery` fixtures for unit and most contract tests.
|
||||
1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, 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,6 +51,41 @@ 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,7 +19,6 @@ def project(db, workspace, create_user):
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
cycle_view=True,
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project,
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# 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
|
||||
@@ -1,216 +0,0 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from 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,23 +366,6 @@ 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,7 +5,6 @@
|
||||
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
|
||||
@@ -13,8 +12,6 @@ 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
|
||||
@@ -305,10 +302,9 @@ 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.
|
||||
# next_path must start with "/" per validate_next_path (otherwise it's discarded).
|
||||
# Use Django client to test the redirect flow without following redirects
|
||||
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},
|
||||
@@ -319,8 +315,8 @@ class TestMagicSignIn:
|
||||
assert response.status_code == 302
|
||||
assert "error_code" not in response.url
|
||||
|
||||
# Check that the redirect URL contains the next_path (URL-encoded, leading slash → %2F)
|
||||
assert "workspaces" in response.url
|
||||
# Check that the redirect URL contains the next_path
|
||||
assert next_path in response.url
|
||||
|
||||
# The user should now be authenticated
|
||||
assert "_auth_user_id" in django_client.session
|
||||
@@ -431,198 +427,3 @@ 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
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# 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,7 +78,6 @@ 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())
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,395 +0,0 @@
|
||||
# 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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user