mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 11:54:36 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc55bc2a48 | ||
|
|
eb34e1c118 |
No files matched your search
@@ -37,7 +37,7 @@ jobs:
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
build_key: ${{ steps.vars.outputs.build_key }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -55,7 +55,6 @@ jobs:
|
||||
run: |
|
||||
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
|
||||
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
BUNDLE_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
@@ -64,10 +63,15 @@ jobs:
|
||||
--output text)
|
||||
echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Image content = bundle + docker build context, so the build key
|
||||
# combines both.
|
||||
CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12)
|
||||
echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
# The image set is a single block, so a single set-level check is
|
||||
# enough: `promote` drops a marker object in S3 only after every
|
||||
# image was built AND every branch tag was moved. Marker present
|
||||
# means there is nothing at all to do for this commit.
|
||||
# means there is nothing at all to do for this build key.
|
||||
- name: Check if this image set is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -77,13 +81,13 @@ jobs:
|
||||
run: |
|
||||
if aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
|
||||
--key "markers/images-${{ steps.vars.outputs.build_key }}" \
|
||||
> /dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### ⏭️ Image set build skipped"
|
||||
echo ""
|
||||
echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`."
|
||||
echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
@@ -93,7 +97,7 @@ jobs:
|
||||
# prune stale bundles while at it.
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
@@ -165,7 +169,7 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip"
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
echo "Bundle not found in host cache; falling back to S3."
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
@@ -205,7 +209,7 @@ jobs:
|
||||
sbom: true
|
||||
# Immutable tag only; branch tags are moved atomically for the
|
||||
# whole image set by the `promote` job.
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }}
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
|
||||
@@ -241,7 +245,7 @@ jobs:
|
||||
for image in $ALL_IMAGES; do
|
||||
docker buildx imagetools create \
|
||||
-t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \
|
||||
"${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}"
|
||||
"${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}"
|
||||
done
|
||||
|
||||
# The marker is written LAST: its presence certifies that all five
|
||||
@@ -253,11 +257,11 @@ jobs:
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
echo "${{ github.run_id }}" | aws s3 cp - \
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}"
|
||||
{
|
||||
echo "### ✅ Image set promoted"
|
||||
echo ""
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: "CI: Exporter"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-exporter:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Exporter Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
./scripts/test
|
||||
@@ -80,40 +80,9 @@ jobs:
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
PLAYWRIGHT_REPORTER: list,json
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
|
||||
run: |
|
||||
./scripts/test-e2e
|
||||
|
||||
- name: Flaky summary
|
||||
if: always()
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
if [ ! -f report.json ]; then
|
||||
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -r '
|
||||
[ .. | objects
|
||||
| select(has("tests") and has("file"))
|
||||
| select(any(.tests[]; .status == "flaky"))
|
||||
| "- `\(.file):\(.line)` — \(.title)"
|
||||
] as $f
|
||||
| "## Flaky tests: \($f | length)\n"
|
||||
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
|
||||
' report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload JSON report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-json-report
|
||||
path: frontend/report.json
|
||||
overwrite: true
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "CI: MCP"
|
||||
name: "MCP CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -24,7 +24,6 @@ opencode.json
|
||||
!AGENTS.md
|
||||
!CODE_OF_CONDUCT.md
|
||||
!SECURITY.md
|
||||
!HIGHLIGHTS.md
|
||||
/*.png
|
||||
/*.svg
|
||||
/*.sql
|
||||
@@ -97,7 +96,6 @@ opencode.json
|
||||
/.idea
|
||||
*.iml
|
||||
/.claude
|
||||
/CLAUDE.md
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
|
||||
@@ -3,8 +3,11 @@ description: Execute a ready plan end-to-end — create a GitHub issue, branch i
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Implement Plan
|
||||
|
||||
This command is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. Follow these steps in order.
|
||||
the plan already prepared in the current session context — it does not take
|
||||
extra arguments. Follow these steps in order.
|
||||
|
||||
## 1. Create the issue
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Fix Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
@@ -0,0 +1,79 @@
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
|
||||
2. Determine the diff or code to review from the provided context.
|
||||
3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
|
||||
4. Read the diff and the surrounding context for each changed file.
|
||||
5. Review across all five axes: correctness, readability, architecture, security, performance.
|
||||
6. Produce the review using this structure:
|
||||
- **Summary**: One-paragraph overview of the change and its impact
|
||||
- **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix)
|
||||
- **Other Findings**: Medium/Low issues and suggestions
|
||||
- **Testing Recommendations**: Missing test coverage or test quality issues
|
||||
- **Positive Observations**: What was done well (brief, specific)
|
||||
- **Verdict**: Approve / Request Changes / Needs Discussion
|
||||
7. For each finding:
|
||||
- State the severity (Critical / High / Medium / Low / Suggestion)
|
||||
- Identify the file and line
|
||||
- Describe failure circumstances
|
||||
- **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code
|
||||
- **For Medium/Low**: Describe the fix clearly; code snippet optional
|
||||
- If multiple approaches exist, briefly note trade-offs
|
||||
8. **Perform a second review pass if the change is complex:**
|
||||
- **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed
|
||||
- **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings
|
||||
- Second pass checks:
|
||||
- Validate severity assignments: Are Critical/High findings truly blockers?
|
||||
- Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass
|
||||
- Remove false positives: Discard findings that aren't real issues
|
||||
- Verify fixes: Are the proposed solutions actually correct and complete?
|
||||
|
||||
## Strong Rules
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Do not modify any code and do not create a commit — this command only reviews.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. If tests are missing for new functionality, flag it as High severity.
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
## Expected Format
|
||||
|
||||
```
|
||||
## Review Summary
|
||||
[1-2 sentences on what the change does and overall assessment]
|
||||
|
||||
## Critical/High Findings
|
||||
### [Severity] file.ts:123
|
||||
**Issue**: [Description of the problem]
|
||||
**Impact**: [What could go wrong]
|
||||
**Fix**:
|
||||
```[language]
|
||||
// Current code
|
||||
[problematic code]
|
||||
|
||||
// Fixed code
|
||||
[corrected code]
|
||||
```
|
||||
[Optional: note trade-offs if multiple approaches exist]
|
||||
|
||||
## Other Findings
|
||||
### [Severity] file.ts:456
|
||||
**Issue**: [Description]
|
||||
**Fix**: [Clear description; code snippet optional]
|
||||
|
||||
## Testing Recommendations
|
||||
[List specific test cases that should be added]
|
||||
|
||||
## Positive Observations
|
||||
[2-3 specific things done well]
|
||||
|
||||
## Verdict
|
||||
[Approve / Request Changes / Needs Discussion]
|
||||
[If Request Changes: list the must-fix items]
|
||||
```
|
||||
+17
-9
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: code-review
|
||||
name: code-review-and-quality
|
||||
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
||||
---
|
||||
|
||||
@@ -106,8 +106,6 @@ For detailed security guidance, see `security-and-hardening`.
|
||||
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
|
||||
| **Suggestion:** | Worth considering | Not required, but improves the code |
|
||||
|
||||
**Unique finding IDs.** Assign every finding a stable identifier: `F1`, `F2`, `F3`, … numbered in order of severity (Critical first, then High, Medium, Low, Suggestion). Use the ID everywhere the finding is mentioned — in section headers, in the verdict, in follow-up discussion. Never renumber within a review. Example: `**F3 (High)** — `app/validate.cljs:42` — duplicate branch logic…`.
|
||||
|
||||
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
|
||||
|
||||
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
|
||||
@@ -124,11 +122,11 @@ Briefly explain what the code does and give an overall assessment.
|
||||
|
||||
### Critical and High-Priority Issues
|
||||
|
||||
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. Each finding gets its unique ID (`F1`, `F2`, …). For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
|
||||
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
|
||||
|
||||
### Other Findings
|
||||
|
||||
List medium- and low-priority issues, including maintainability and design concerns. Continue the ID sequence started above (`F3`, `F4`, …).
|
||||
List medium- and low-priority issues, including maintainability and design concerns.
|
||||
|
||||
### Suggested Refactoring
|
||||
|
||||
@@ -150,8 +148,6 @@ Choose one:
|
||||
- **Approve with minor changes** — Good to merge after addressing low/medium issues
|
||||
- **Request changes** — Critical or high issues must be resolved before merge
|
||||
|
||||
List the finding IDs the verdict depends on (e.g. "Request changes: F1, F4").
|
||||
|
||||
## Change Sizing
|
||||
|
||||
Small, focused changes are easier to review, faster to merge, and safer to deploy.
|
||||
@@ -235,13 +231,25 @@ For supply-chain risk triage, follow the `security-and-hardening` skill.
|
||||
|
||||
## Verification
|
||||
|
||||
Before emitting the verdict, verify the change as it stands. This is the reviewer's own due diligence — it covers the state of the code at review time, not the later resolution of findings (fixing findings is the author's job; confirming them is a new review):
|
||||
After review is complete:
|
||||
|
||||
- [ ] Tests pass — run them yourself, don't trust the claim
|
||||
- [ ] All Critical issues are resolved
|
||||
- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification
|
||||
- [ ] Tests pass
|
||||
- [ ] Build succeeds
|
||||
- [ ] The verification story is documented (what changed, how it was verified)
|
||||
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
|
||||
|
||||
## Multi-Model Review Pattern
|
||||
|
||||
Use different models for different review perspectives:
|
||||
|
||||
```
|
||||
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
|
||||
```
|
||||
|
||||
Different models have different blind spots.
|
||||
|
||||
## See Also
|
||||
|
||||
- For detailed security review guidance, see `security-and-hardening`
|
||||
@@ -1,315 +0,0 @@
|
||||
---
|
||||
name: plan-review
|
||||
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
|
||||
---
|
||||
|
||||
# Plan Review
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality.
|
||||
|
||||
**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After the planner skill produces a plan
|
||||
- Before starting implementation on any non-trivial task
|
||||
- When reviewing a plan written by another agent or a human
|
||||
- When a plan feels too large, vague, or risky to start
|
||||
|
||||
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
|
||||
|
||||
## The Six-Axis Review
|
||||
|
||||
Every plan gets evaluated across these dimensions:
|
||||
|
||||
### 1. Completeness
|
||||
|
||||
Does the plan cover everything needed to implement successfully?
|
||||
|
||||
- Is the **context** clear? (What problem, why now, what's the goal?)
|
||||
- Are **affected modules** identified with paths?
|
||||
- Are **architecture decisions** documented with rationale?
|
||||
- Is there a **testing strategy**?
|
||||
- Are **verification commands** explicit (not "run the tests")?
|
||||
- Are **open questions** listed (not buried in someone's head)?
|
||||
- Is there a **parallelization** assessment for multi-task plans?
|
||||
|
||||
**Missing any of these is a gap, not a nit.**
|
||||
|
||||
### 2. Task Quality
|
||||
|
||||
Are the tasks well-defined and independently executable?
|
||||
|
||||
- Does every task have **acceptance criteria**? (Testable, not vague)
|
||||
- Does every task have **verification steps**?
|
||||
- Are tasks **sized appropriately**? (XS–M is ideal, L is acceptable, XL must be split)
|
||||
- Are **dependencies** between tasks explicitly stated?
|
||||
- Are **files likely touched** listed?
|
||||
- Is each task a **single, self-contained change**? (Not "implement the whole feature")
|
||||
- Could a skilled implementer pick up any task and execute it without asking clarifying questions?
|
||||
|
||||
### 3. Architecture & Sequencing
|
||||
|
||||
Is the plan structured so implementation flows correctly?
|
||||
|
||||
- Does implementation order follow the **dependency graph** (foundations first)?
|
||||
- Are tasks **vertically sliced** (feature paths) rather than horizontally layered?
|
||||
- Does each task leave the system in a **working state**?
|
||||
- Are there **checkpoints** between major phases?
|
||||
- Are **high-risk tasks early** (fail fast)?
|
||||
- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans)
|
||||
|
||||
### 4. Risk Coverage
|
||||
|
||||
Are the hard parts acknowledged and mitigated?
|
||||
|
||||
- Are **edge cases** identified?
|
||||
- Are **breaking changes** or **migration concerns** noted?
|
||||
- Are **security implications** considered?
|
||||
- Are **performance implications** considered?
|
||||
- Are **external dependencies** or integration risks flagged?
|
||||
- Is there a plan for **rollback** if something goes wrong?
|
||||
- Are **data integrity** risks addressed (what happens if a migration fails mid-way)?
|
||||
|
||||
### 5. Actionability
|
||||
|
||||
Can an implementer actually execute this?
|
||||
|
||||
- Are **file paths** specific (not "update the relevant files")?
|
||||
- Are **function/method names** mentioned where applicable?
|
||||
- Are **verification commands** copy-pasteable (not "run the linter")?
|
||||
- Are **test commands** project-specific (not generic)?
|
||||
- Is the **code shape** described where the implementation isn't obvious?
|
||||
- Are **conventions** referenced (naming, patterns, existing utilities to reuse)?
|
||||
- Does the plan reference **existing code** the implementer should read first?
|
||||
|
||||
### 6. Proposed Code Quality *(when the plan includes implementation details)*
|
||||
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
|
||||
|
||||
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
|
||||
- **Readability:** Are proposed names descriptive and consistent with project conventions?
|
||||
- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)?
|
||||
- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design?
|
||||
- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design?
|
||||
|
||||
**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis.
|
||||
|
||||
## Structural Remedies
|
||||
|
||||
When you flag a structural problem in a plan, propose the fix — not just the problem:
|
||||
|
||||
- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable.
|
||||
- **Missing acceptance criteria:** Draft 2–3 specific, testable conditions for the task.
|
||||
- **Wrong sequencing:** Identify the dependency and propose the correct order.
|
||||
- **No checkpoints:** Suggest where checkpoints should go (typically after every 2–3 tasks).
|
||||
- **Vague verification:** Replace "run tests" with the actual project command.
|
||||
- **Horizontal slicing:** Restructure into vertical feature paths.
|
||||
- **Missing risk section:** Draft the risks you can identify from the plan content.
|
||||
|
||||
Prefer the remedy that makes the plan immediately actionable over one that just flags the gap.
|
||||
|
||||
## Plan Sizing
|
||||
|
||||
Plans should be scoped to a single deliverable:
|
||||
|
||||
```
|
||||
1–5 tasks → Good. A focused feature or bug fix.
|
||||
6–10 tasks → Acceptable for a moderate feature.
|
||||
11–15 tasks → Large. Consider splitting into phases.
|
||||
15+ tasks → Too large. Split into multiple plans.
|
||||
```
|
||||
|
||||
**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan.
|
||||
|
||||
## Categorize Findings
|
||||
|
||||
Label every comment with its severity so the author knows what's required vs optional:
|
||||
|
||||
| Prefix | Meaning | Author Action |
|
||||
|--------|---------|---------------|
|
||||
| *(no prefix)* | Required change | Must address before implementation starts |
|
||||
| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach |
|
||||
| **Nit:** | Minor, optional | Author may ignore — wording, formatting |
|
||||
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
|
||||
| **FYI** | Informational only | No action needed — context for future reference |
|
||||
|
||||
**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review.
|
||||
|
||||
## Review Process
|
||||
|
||||
### Step 1: Understand the Goal
|
||||
|
||||
Before evaluating structure, understand intent:
|
||||
|
||||
```
|
||||
- What is this plan trying to accomplish?
|
||||
- What problem does it solve?
|
||||
- What does "done" look like?
|
||||
```
|
||||
|
||||
### Step 2: Check Completeness First
|
||||
|
||||
Scan for missing sections before diving into content:
|
||||
|
||||
```
|
||||
- Context present?
|
||||
- Affected modules listed?
|
||||
- Architecture decisions documented?
|
||||
- Risks acknowledged?
|
||||
- Testing strategy defined?
|
||||
- Verification commands explicit?
|
||||
```
|
||||
|
||||
### Step 3: Review Task Quality
|
||||
|
||||
Walk through each task:
|
||||
|
||||
```
|
||||
For each task:
|
||||
1. Can I tell exactly what to build?
|
||||
2. Are acceptance criteria specific and testable?
|
||||
3. Is the size reasonable (not XL)?
|
||||
4. Are dependencies clear?
|
||||
5. Would I know which files to touch?
|
||||
```
|
||||
|
||||
### Step 4: Validate Sequencing
|
||||
|
||||
Check the dependency graph:
|
||||
|
||||
```
|
||||
- Are foundations built first?
|
||||
- Does each task leave the system working?
|
||||
- Are checkpoints placed correctly?
|
||||
- Are high-risk items early?
|
||||
- Is it vertically sliced?
|
||||
```
|
||||
|
||||
### Step 5: Assess Actionability
|
||||
|
||||
Put yourself in the implementer's shoes:
|
||||
|
||||
```
|
||||
- Could I pick up task 1 and start coding without asking any questions?
|
||||
- Are the verification commands copy-pasteable?
|
||||
- Are file paths and function names specific?
|
||||
- Is existing code referenced where I'd need to read it?
|
||||
```
|
||||
|
||||
### Step 6: Verify the Verification Story
|
||||
|
||||
Check that the plan can actually confirm it worked:
|
||||
|
||||
```
|
||||
- What tests should pass after implementation?
|
||||
- What build/compile commands are relevant?
|
||||
- What manual checks are needed?
|
||||
- How do we know the feature works end-to-end?
|
||||
```
|
||||
|
||||
### Step 7: Evaluate Proposed Code Quality *(if applicable)*
|
||||
|
||||
If the plan includes code snippets, types, or API designs:
|
||||
|
||||
```
|
||||
- Load code-review skill for criteria
|
||||
- Check proposed signatures for edge cases
|
||||
- Verify naming follows project conventions
|
||||
- Confirm abstractions follow existing patterns
|
||||
- Scan for security vectors in proposed APIs
|
||||
- Check for performance issues in proposed data structures
|
||||
```
|
||||
|
||||
## Review Checklist
|
||||
|
||||
```markdown
|
||||
## Review: [Plan title]
|
||||
|
||||
### Completeness
|
||||
- [ ] Context explains the problem and goal
|
||||
- [ ] Affected modules are listed with paths
|
||||
- [ ] Architecture decisions have rationale
|
||||
- [ ] Testing strategy is defined
|
||||
- [ ] Verification commands are explicit and project-specific
|
||||
- [ ] Open questions are listed
|
||||
|
||||
### Task Quality
|
||||
- [ ] Every task has acceptance criteria
|
||||
- [ ] Every task has verification steps
|
||||
- [ ] Tasks are sized XS–M (L acceptable, XL must be split)
|
||||
- [ ] Task dependencies are stated
|
||||
- [ ] Files likely touched are listed
|
||||
|
||||
### Architecture & Sequencing
|
||||
- [ ] Order follows dependency graph (foundations first)
|
||||
- [ ] Vertically sliced (not horizontal layers)
|
||||
- [ ] Each task leaves system working
|
||||
- [ ] Checkpoints exist between phases
|
||||
- [ ] High-risk tasks are early
|
||||
|
||||
### Risk Coverage
|
||||
- [ ] Edge cases identified
|
||||
- [ ] Breaking changes / migrations noted
|
||||
- [ ] Security implications considered
|
||||
- [ ] Performance implications considered
|
||||
- [ ] Rollback strategy exists (if applicable)
|
||||
|
||||
### Actionability
|
||||
- [ ] File paths are specific
|
||||
- [ ] Verification commands are copy-pasteable
|
||||
- [ ] Existing code to read is referenced
|
||||
- [ ] Conventions and patterns are noted
|
||||
|
||||
### Proposed Code Quality *(if plan includes implementation details)*
|
||||
- [ ] Proposed types/signatures handle edge cases
|
||||
- [ ] Proposed names follow project conventions
|
||||
- [ ] Proposed abstractions follow existing patterns
|
||||
- [ ] No security vectors in proposed APIs
|
||||
- [ ] No performance issues in proposed structures
|
||||
|
||||
### Verdict
|
||||
- [ ] **Approve** — Ready to implement
|
||||
- [ ] **Request changes** — Gaps must be addressed
|
||||
```
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. |
|
||||
| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. |
|
||||
| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. |
|
||||
| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. |
|
||||
| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. |
|
||||
| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. |
|
||||
| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. |
|
||||
| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- No acceptance criteria on any task
|
||||
- Tasks that say "implement the feature" without specifics
|
||||
- No verification steps anywhere in the plan
|
||||
- All tasks are XL-sized
|
||||
- No checkpoints between phases
|
||||
- Dependency order isn't considered (e.g., API handler before domain model)
|
||||
- No testing strategy
|
||||
- Verification commands are generic ("run tests") instead of project-specific
|
||||
- Plan has 20+ tasks (scope too large for one plan)
|
||||
- No risk section on a plan with migrations, breaking changes, or security implications
|
||||
- Horizontal slicing (all domain, then all services, then all API)
|
||||
- File paths are vague ("update the relevant files")
|
||||
- Missing open questions section despite stated unknowns
|
||||
- Proposed code ignores project conventions or existing patterns
|
||||
- Proposed types use gratuitous `any`/`unknown`/optional without justification
|
||||
- Proposed APIs don't validate input at boundaries
|
||||
|
||||
## See Also
|
||||
|
||||
- For producing plans, use the `planner` skill
|
||||
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
|
||||
- For security-specific concerns, see `security-and-hardening`
|
||||
- For testing strategy guidance, see `testing`
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md.
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-<title>.md.
|
||||
---
|
||||
|
||||
# Planner
|
||||
|
||||
Read-only senior software architect role for Penpot. Produces structured
|
||||
implementation plans with task breakdowns that engineers or other agents can
|
||||
execute. Never writes or modifies code.
|
||||
implementation plans that engineers or other agents can execute. Never writes
|
||||
or modifies code.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -18,29 +18,24 @@ execute. Never writes or modifies code.
|
||||
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
|
||||
- The user is about to start non-trivial work and wants a bite-sized task
|
||||
breakdown.
|
||||
- A task feels too large or vague to start.
|
||||
- Work needs to be parallelized across multiple agents or sessions.
|
||||
|
||||
Do **not** use this skill to actually implement anything — it is read-only.
|
||||
|
||||
**When NOT to use:** Single-file changes with obvious scope, or when the spec
|
||||
already contains well-defined tasks.
|
||||
|
||||
## Role
|
||||
|
||||
You help users understand the Penpot codebase, design solutions, and produce
|
||||
implementation plans that other agents or developers can execute. The plan
|
||||
tells them what to build and how to verify it, task by task.
|
||||
You are a Senior Software Architect working on Penpot, an open-source design
|
||||
tool. Your sole responsibility is planning and analysis — you do NOT write or
|
||||
modify code.
|
||||
|
||||
The implementer reads the project's agent docs (`AGENTS.md`, project memories
|
||||
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
|
||||
before working. Reference those memories instead of re-explaining tooling,
|
||||
conventions, or test design — explain in the plan only what they do not cover.
|
||||
You help users understand the codebase, design solutions, and create detailed
|
||||
implementation plans that other agents or developers can execute. Document
|
||||
everything they need to know: which files to touch for each task, code patterns,
|
||||
tests, and how to verify correctness. Apply DRY and KISS principles.
|
||||
|
||||
Do **not** suggest commit messages or commit names anywhere in your plans or
|
||||
responses — committing is the implementer's responsibility.
|
||||
responses — committing is the developer's responsibility.
|
||||
|
||||
## CRITICAL: Required Reading Before Planning
|
||||
## Required Reading Before Planning
|
||||
|
||||
Before drafting any plan, work through the project's own guidance:
|
||||
|
||||
@@ -55,8 +50,6 @@ Before drafting any plan, work through the project's own guidance:
|
||||
|
||||
Skipping this step is the #1 cause of incorrect or incomplete plans.
|
||||
|
||||
---
|
||||
|
||||
## The Planning Process
|
||||
|
||||
### Phase 1: Architecture Analysis
|
||||
@@ -71,42 +64,16 @@ Skipping this step is the #1 cause of incorrect or incomplete plans.
|
||||
|
||||
### Phase 2: Task Breakdown
|
||||
|
||||
#### Identify the Dependency Graph
|
||||
|
||||
Map what depends on what, following the monorepo's module dependency graph:
|
||||
|
||||
```
|
||||
common (shared types, schemas — no deps)
|
||||
│
|
||||
├── backend (depends common)
|
||||
│ ├── RPC handlers
|
||||
│ └── persistence / migrations
|
||||
│
|
||||
├── frontend (depends common, render-wasm)
|
||||
│ ├── UI components
|
||||
│ └── state / API integration
|
||||
│
|
||||
├── exporter (depends common)
|
||||
│
|
||||
└── render-wasm (consumed by frontend)
|
||||
```
|
||||
|
||||
Implementation order follows the dependency graph bottom-up: build shared
|
||||
foundations first, then layer consumers on top.
|
||||
Implementation order follows the monorepo's dependency graph:
|
||||
`frontend -> common`, `backend -> common`, `exporter -> common`,
|
||||
`frontend -> render-wasm`. Build shared foundations first, then layer
|
||||
consumers on top.
|
||||
|
||||
#### Slice Vertically
|
||||
|
||||
Instead of building all of common, then all of backend, then all of frontend —
|
||||
build one complete feature path at a time:
|
||||
|
||||
**Bad (horizontal slicing):**
|
||||
```
|
||||
Task 1: Build all common types
|
||||
Task 2: Build all backend handlers
|
||||
Task 3: Build all frontend components
|
||||
```
|
||||
|
||||
**Good (vertical slicing):**
|
||||
```
|
||||
Task 1: common data types + schema ← foundation
|
||||
Task 2: backend RPC handler + persistence
|
||||
@@ -122,58 +89,39 @@ Each task follows this structure:
|
||||
```markdown
|
||||
## Task [N]: [Short descriptive title]
|
||||
|
||||
**Description:** One or two paragraphs explaining what this task accomplishes.
|
||||
Should be clear and concise.
|
||||
|
||||
**Rationale:** Why this task exists and why this approach over the obvious
|
||||
alternatives — design decisions, trade-offs, constraints discovered during
|
||||
analysis. One or two sentences; skip only if genuinely trivial.
|
||||
|
||||
**Code sketch (optional):** Signature-, type-, or shape-level example when the
|
||||
intended interface is non-obvious. Keep it short — a skeleton that fixes the
|
||||
contract (function signature, model fields, error shape), never a full
|
||||
implementation. Omit when the task is mechanical.
|
||||
**Description:** One paragraph explaining what this task accomplishes.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] [Specific, testable condition]
|
||||
- [ ] [Specific, testable condition]
|
||||
|
||||
**Verification:**
|
||||
- [ ] Relevant tests pass (module-specific test command).
|
||||
- [ ] Lint/formatter passes (module-specific check command), if applicable.
|
||||
- [ ] The core flow works end-to-end, if applicable.
|
||||
- [ ] Tests pass (module-specific test command)
|
||||
- [ ] Lint/formatter passes (module-specific check command)
|
||||
|
||||
**Dependencies:** [Task numbers this depends on, or "None"]
|
||||
|
||||
**Files likely touched:**
|
||||
- `path/to/file.clj`
|
||||
- `path/to/file_test.clj`
|
||||
|
||||
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
|
||||
```
|
||||
|
||||
Replace "module-specific test command" with the actual commands for the module
|
||||
(e.g. `clojure -M:dev:test` for backend/common,
|
||||
`npx shadow-cljs compile test && npx karma start` for frontend, or the
|
||||
commands noted in the module's core memory).
|
||||
|
||||
When possible, design each task with TDD in mind: acceptance criteria double
|
||||
as a test list, and the natural first step of the task is writing those tests
|
||||
before the implementation. Some tasks resist this (config, migrations, pure
|
||||
wiring) — for those, keep the usual verification steps.
|
||||
(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend,
|
||||
or the commands noted in the module's core memory).
|
||||
|
||||
#### Estimate Scope
|
||||
|
||||
| Size | Files | Scope | Example |
|
||||
|------|-------|-------|---------|
|
||||
| **XS** | 1 | Single function, config change, or schema tweak | Add a validation rule |
|
||||
| **S** | 1-2 | One handler or component method | Add a new RPC endpoint |
|
||||
| **M** | 3-5 | One vertical feature slice | Bookmark CRUD with tests |
|
||||
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
|
||||
| **XL** | 8+ | **Too large — break it down further** | — |
|
||||
| Size | Files | Scope |
|
||||
|------|-------|-------|
|
||||
| **XS** | 1 | Single function, config change, or schema tweak |
|
||||
| **S** | 1-2 | One handler or component method |
|
||||
| **M** | 3-5 | One vertical feature slice |
|
||||
| **L** | 5-8 | Multi-component feature |
|
||||
| **XL** | 8+ | **Too large — break it down further** |
|
||||
|
||||
If a task is XL, it should be broken into smaller tasks. Agents perform best
|
||||
on S and M tasks.
|
||||
If a task is L or larger, break it into smaller tasks. Agents perform best on
|
||||
S and M tasks.
|
||||
|
||||
**When to break a task down further:**
|
||||
- It would take more than one focused session
|
||||
@@ -193,11 +141,11 @@ Arrange tasks so that:
|
||||
Add explicit checkpoints with the relevant module commands:
|
||||
|
||||
```markdown
|
||||
### Checkpoint: After Tasks 1-3
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
- [ ] The relevant build or compilation passes, if applicable.
|
||||
- [ ] The core flow works end-to-end.
|
||||
- [ ] Review with human before proceeding.
|
||||
## Checkpoint: After Tasks 1-3
|
||||
- [ ] All tests pass (module-specific command)
|
||||
- [ ] Lint/format passes (module-specific command)
|
||||
- [ ] Core flow works end-to-end
|
||||
- [ ] Review with human before proceeding
|
||||
```
|
||||
|
||||
## Requirements
|
||||
@@ -211,7 +159,7 @@ Add explicit checkpoints with the relevant module commands:
|
||||
- Apply DRY and KISS principles to the proposed implementation.
|
||||
- Define a testing strategy aligned with each affected module's tooling.
|
||||
- Every task must have acceptance criteria and verification steps.
|
||||
- Checkpoints must exist after every 2-3 tasks.
|
||||
- Checkpoints must exist between major phases.
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -220,8 +168,7 @@ Add explicit checkpoints with the relevant module commands:
|
||||
`.opencode/plans/`.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
`find`, `cat`, `bat`).
|
||||
- You do **not** execute shell commands beyond read-only searches.
|
||||
- Your output is a structured plan or analysis, ready for handoff to an
|
||||
engineer agent or developer.
|
||||
|
||||
@@ -241,9 +188,8 @@ slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
|
||||
`.opencode/plans/` directory if it does not exist.
|
||||
|
||||
IMPORTANT: The plan agent has write permission specifically for
|
||||
`.opencode/plans/` — always attempt the write. If the user explicitly provides
|
||||
a target file path, use that path instead of the default.
|
||||
Always attempt the write. If the user explicitly provides a target file path,
|
||||
use that path instead of the default.
|
||||
|
||||
### Plan Document Template
|
||||
|
||||
@@ -266,75 +212,41 @@ a target file path, use that path instead of the default.
|
||||
security implications.]
|
||||
|
||||
## Approach
|
||||
[A short strategy summary: 3-5 sentences describing the overall approach and
|
||||
the shape of the dependency graph (what depends on what, what gets built
|
||||
first). High-level only — the task-by-task detail lives in the Task List.]
|
||||
[Step-by-step implementation plan with file paths, function names, and code
|
||||
shape where applicable. Group steps into atomic, ordered tasks.]
|
||||
|
||||
## Task List
|
||||
|
||||
Each task uses the full task structure defined in
|
||||
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
|
||||
verification, dependencies, files, estimated scope, and optional code sketch.
|
||||
Never reduce a task to a one-line checkbox; the plan must be self-contained
|
||||
and executable without other context.
|
||||
### Phase 1: Foundation
|
||||
- [ ] Task 1: ...
|
||||
- [ ] Task 2: ...
|
||||
|
||||
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
|
||||
into phases, milestones, or sprints; ordering and dependencies are already
|
||||
captured per task. Insert a checkpoint after every 2-3 tasks.
|
||||
### Checkpoint: Phase 1
|
||||
- [ ] Tests pass, lint/formatter clean (module-specific commands)
|
||||
|
||||
## Task 1: [Short descriptive title]
|
||||
### Phase 2: Core Features
|
||||
- [ ] Task 3: ...
|
||||
- [ ] Task 4: ...
|
||||
|
||||
**Description:** [What this task accomplishes.]
|
||||
### Checkpoint: Phase 2
|
||||
- [ ] End-to-end flow works
|
||||
|
||||
**Rationale:** [Why this approach over the alternatives.]
|
||||
### Phase 3: Polish
|
||||
- [ ] Task 5: ...
|
||||
- [ ] Task 6: ...
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] [Specific, testable condition]
|
||||
### Checkpoint: Complete
|
||||
- [ ] All acceptance criteria met
|
||||
- [ ] Ready for review
|
||||
|
||||
**Verification:**
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files likely touched:**
|
||||
- `path/to/file`
|
||||
|
||||
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
|
||||
|
||||
**Code sketch (optional):** [Short contract-level example, only if the shape
|
||||
is non-obvious.]
|
||||
|
||||
## Task 2: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Task 3: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
### Checkpoint: After Tasks 1-3
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
- [ ] The relevant build or compilation passes, if applicable.
|
||||
- [ ] The core flow works end-to-end.
|
||||
- [ ] Review with human before proceeding.
|
||||
|
||||
## Task 4: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Task 5: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Verification & Testing
|
||||
[How to verify each task and the whole plan: the project's real test, lint,
|
||||
build, and run commands (extracted during Required Reading), coverage
|
||||
expectations, and manual checks. Consult each module's core memory for the
|
||||
exact commands.]
|
||||
## Testing Strategy
|
||||
[How to verify: which test commands to run per module, what cases to cover,
|
||||
manual verification steps, lint/format checks. Consult each module's core
|
||||
memory for the exact commands.]
|
||||
|
||||
## Parallelization Opportunities
|
||||
- **Safe to parallelize:** Independent feature slices across separate
|
||||
modules, tests for already-implemented features, documentation
|
||||
modules, tests for already-implemented features
|
||||
- **Must be sequential:** Shared common schema changes, database migrations
|
||||
- **Needs coordination:** Features that share a contract (define the contract
|
||||
first, then parallelize)
|
||||
@@ -347,31 +259,13 @@ When the plan is purely analytical (e.g. a code review or feasibility study
|
||||
with no implementation), skip the **Approach** and **Task List** sections and
|
||||
lead with **Findings** instead, keeping the rest of the structure.
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "I'll figure it out as I go" | That's how you end up with a tangled mess and rework. 10 minutes of planning saves hours. |
|
||||
| "The tasks are obvious" | Write them down anyway. Explicit tasks surface hidden dependencies and forgotten edge cases. |
|
||||
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
|
||||
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Delivering prose without a task breakdown
|
||||
- Tasks that say "implement the feature" without acceptance criteria
|
||||
- No verification steps in the plan
|
||||
- All tasks are XL-sized
|
||||
- No checkpoints between tasks
|
||||
- Dependency order isn't considered
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before delivering the plan, confirm:
|
||||
Before starting implementation, confirm:
|
||||
|
||||
- [ ] Every task has acceptance criteria
|
||||
- [ ] Every task has a verification step
|
||||
- [ ] Task dependencies are identified and ordered correctly
|
||||
- [ ] No task is XL or larger — break it down instead
|
||||
- [ ] Checkpoints exist after every 2-3 tasks
|
||||
- [ ] The plan is ready for human review
|
||||
- [ ] No task touches more than ~5 files
|
||||
- [ ] Checkpoints exist between major phases
|
||||
- [ ] The human has reviewed and approved the plan
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: ste
|
||||
description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it.
|
||||
---
|
||||
|
||||
# ASD-STE100 Simplified Technical English
|
||||
|
||||
Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it.
|
||||
|
||||
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
|
||||
|
||||
## Step 0 — Classify the text
|
||||
|
||||
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
|
||||
|
||||
## Core rules
|
||||
|
||||
### Sentences
|
||||
- Procedural: maximum **20 words** per sentence.
|
||||
- Descriptive: maximum **25 words** per sentence.
|
||||
- Maximum **6 sentences** per paragraph. One topic per paragraph.
|
||||
- One instruction per sentence. Two actions in one sentence only if they occur at the same time.
|
||||
- Put a condition BEFORE its command: "If the pressure decreases, close the valve."
|
||||
- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure."
|
||||
- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word.
|
||||
|
||||
### Verbs
|
||||
- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective.
|
||||
- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form.
|
||||
- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging").
|
||||
- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant.
|
||||
- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened."
|
||||
- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file."
|
||||
- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur."
|
||||
- No phrasal verbs: "go down" → "decrease," "set up" → "install," "carry out" → "do."
|
||||
|
||||
### Words
|
||||
- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it.
|
||||
- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill.
|
||||
- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb.
|
||||
- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle."
|
||||
- American English spelling.
|
||||
- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc."
|
||||
|
||||
### Punctuation
|
||||
- No semicolons — write two sentences.
|
||||
- Parentheses only for references, abbreviations, and item numbers.
|
||||
- Hyphenate words that act as one unit; a hyphenated word counts as one word.
|
||||
- No contractions.
|
||||
|
||||
### Warnings, cautions, notes
|
||||
- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction.
|
||||
- Start a warning or caution with the command or condition, then give the risk:
|
||||
"WARNING: Do not touch the terminal. The terminal has a dangerous voltage."
|
||||
- Notes obey the 25-word descriptive limit.
|
||||
|
||||
## Step 2 — Self-check pass
|
||||
|
||||
After drafting, scan your text once for each of these and fix every hit before you respond:
|
||||
|
||||
1. Any sentence over the 20/25-word limit for its type
|
||||
2. Contractions, semicolons
|
||||
3. "should," "would," "could," "may," "might"
|
||||
4. "has been," "have been," "had been," "is being," "was being"
|
||||
5. -ing words used as verbs
|
||||
6. Missing articles (a/an/the/this) before nouns
|
||||
7. Synonym rotation (the same object under two names)
|
||||
8. Any word in the unapproved column of `references/word-substitutions.md`
|
||||
9. Warnings that state the risk before the command
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short.
|
||||
- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies.
|
||||
|
||||
## What NOT to touch
|
||||
|
||||
Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Worked before/after examples
|
||||
|
||||
## Verb forms
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| We have received the technical reports from HQ. | We received the technical reports from HQ. |
|
||||
| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. |
|
||||
| The test is continued by the operator. | Continue the test. |
|
||||
| The screws should be replaced. | Replace the screws. |
|
||||
| The system is currently running diagnostics. | The system does diagnostic tests now. |
|
||||
|
||||
## Vocabulary and phrasing
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| Ensure file exists before running. | Make sure that the file exists before you run the command. |
|
||||
| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. |
|
||||
| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. |
|
||||
| Make sure that these steps are followed. | Obey these steps. |
|
||||
| Utilize approximately 3 liters of water. | Use about 3 liters of water. |
|
||||
| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. |
|
||||
|
||||
## Noun clusters
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| Main gear door retraction winch handle | Main-gear-door retraction-winch handle |
|
||||
| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection |
|
||||
| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. |
|
||||
|
||||
## Procedural rewrite (condition first, one instruction per sentence)
|
||||
|
||||
Before:
|
||||
> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air.
|
||||
|
||||
After:
|
||||
> 1. Fill the reservoir with the correct fluid.
|
||||
> 2. Attach a clear tube to the bleed screw.
|
||||
> 3. Put the free end of the tube in a container of fluid.
|
||||
> 4. Push the pedal three times. Hold the pedal down.
|
||||
> 5. Open the bleed screw one half turn. Air and fluid flow into the tube.
|
||||
> 6. Close the bleed screw. Release the pedal.
|
||||
> 7. If air continues to come out, do steps 4 thru 6 again.
|
||||
|
||||
## Warnings and cautions (command first, then risk)
|
||||
|
||||
Before:
|
||||
> Note that serious data loss may potentially occur if the --force flag is used against production.
|
||||
|
||||
After:
|
||||
> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source.
|
||||
|
||||
Before:
|
||||
> Touching the terminal could result in electrocution.
|
||||
|
||||
After:
|
||||
> WARNING: Do not touch the terminal. The terminal has a dangerous voltage.
|
||||
|
||||
## Common mistakes checklist
|
||||
|
||||
- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket."
|
||||
- Synonym rotation: check/verify/confirm for the same action → one term, everywhere.
|
||||
- Hedges: "you may want to," "it is recommended that" → an imperative or "must."
|
||||
- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step.
|
||||
- Semicolon joining two clauses → two sentences.
|
||||
- "There are three bolts on the panel" → "The panel has three bolts."
|
||||
@@ -1,68 +0,0 @@
|
||||
# Word substitutions and one-meaning rulings
|
||||
|
||||
Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative.
|
||||
|
||||
## Unapproved → approved
|
||||
|
||||
| Do not use | Use instead |
|
||||
|---|---|
|
||||
| utilize, leverage, employ | use |
|
||||
| commence, initiate, begin, originate | start |
|
||||
| terminate, cease, conclude | stop, end |
|
||||
| ensure, verify, confirm, validate, check | make sure (that), examine |
|
||||
| perform, conduct, execute, carry out | do |
|
||||
| facilitate, assist | help |
|
||||
| obtain, acquire, procure | get |
|
||||
| sufficient, adequate | enough |
|
||||
| approximately | about |
|
||||
| prior to | before |
|
||||
| subsequent to, following (prep.) | after |
|
||||
| adjacent to | near |
|
||||
| accomplish | do |
|
||||
| additional, supplementary | more |
|
||||
| attempt | try |
|
||||
| require, necessitate | need, must |
|
||||
| mandatory | necessary |
|
||||
| indicate, signify | show |
|
||||
| observe (=watch) | look at, examine |
|
||||
| rotate | turn |
|
||||
| deactivate | turn off, set to off |
|
||||
| activate, energize (unless technical verb) | turn on, start |
|
||||
| toxic | poisonous |
|
||||
| in order to | to |
|
||||
| via, by means of | through, with |
|
||||
| due to, owing to | because of |
|
||||
| in the event of/that | if |
|
||||
| accessible | (rewrite: "you can get access to") |
|
||||
| remainder | rest |
|
||||
| demonstrate | show |
|
||||
| modify, alter | change |
|
||||
| construct, fabricate, build | assemble, make |
|
||||
| retain | keep |
|
||||
| locate (=find) | find |
|
||||
| depress (a button) | push, press |
|
||||
| proceed | continue, go |
|
||||
|
||||
## One meaning, one part of speech (canonical rulings)
|
||||
|
||||
- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller").
|
||||
- **test** — noun only: "do a test," never "test the system."
|
||||
- **check** — do not use as a verb for verification → "make sure that" or "examine."
|
||||
- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions."
|
||||
- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season.
|
||||
- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing."
|
||||
- **right** — direction only, never "correct."
|
||||
- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground."
|
||||
- **help** — verb only; the noun is **aid** ("with the aid of a mirror").
|
||||
- **above / below** — physical position only. For quantities: **more than / less than**.
|
||||
- **about** — two approved senses: "approximately" and "on the subject of." Use carefully.
|
||||
- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard.
|
||||
- **level** — approved as noun and adjective (documented exception to the one-POS rule).
|
||||
|
||||
## Frequent-offender function words
|
||||
|
||||
- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**.
|
||||
- **etc.** — delete, or write the full list.
|
||||
- **e.g. / i.e.** — "for example" / "that is."
|
||||
- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant.
|
||||
- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts."
|
||||
@@ -34,8 +34,7 @@ Before writing any test, read:
|
||||
2. Module-specific testing memory for the affected module:
|
||||
- `mem:common/testing` — CLJC unit tests
|
||||
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E
|
||||
- `mem:backend/testing` — JVM clojure.test conventions
|
||||
- `mem:exporter/testing` — exporter unit tests
|
||||
- `mem:backend/core` — JVM clojure.test conventions
|
||||
|
||||
## Key Rules
|
||||
|
||||
|
||||
@@ -212,37 +212,6 @@ superseded it:
|
||||
|
||||
Replace the reference in the changelog entry with the correct merged PR number.
|
||||
|
||||
### 5b. Security advisory (GHSA) entries
|
||||
|
||||
Security advisories fixed in a release are documented in the changelog even
|
||||
though they are **neither milestone issues nor PRs**. The GHSA ID and its
|
||||
description are supplied by the user or the release notes — they never come
|
||||
from the milestone fetch in step 2.
|
||||
|
||||
**Format** (matches the existing precedent in `CHANGES.md`, e.g. the
|
||||
`create-font-variant` arbitrary file read advisory):
|
||||
|
||||
```markdown
|
||||
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link** —
|
||||
only the advisory URL.
|
||||
- The advisory may be **draft/unpublished** at changelog time (the URL 404s
|
||||
publicly). Do **not** web-fetch or verify the URL, and do **not** drop the
|
||||
entry because of that. Rely on the GHSA ID provided by the user.
|
||||
- Derive the description from the supplied advisory title, imperative mood and
|
||||
user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`).
|
||||
- These entries are **invisible to the automation**: they are not returned by
|
||||
`gh.py issues`, not matched by `--compare` (step 3), not part of the PR
|
||||
cross-reference (step 10), and not scanned by the anomaly-report regexes
|
||||
(step 11, which only match `issues/` and `pull/` links). Add them manually.
|
||||
- During pre-flight checks (step 6a) apply only the **backport/duplicate**
|
||||
check: if the same GHSA already appears in an earlier version section, remove
|
||||
it from the current section. Their absence from milestone cross-references
|
||||
is expected, not an anomaly.
|
||||
|
||||
### 6. Read the current CHANGES.md
|
||||
|
||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
||||
@@ -431,8 +400,6 @@ if closed:
|
||||
- ✅ Every merged milestone PR is either in the changelog or excluded by label
|
||||
- ✅ PR and issue counts are internally consistent
|
||||
- ✅ No false-positive PR-to-issue associations
|
||||
- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the
|
||||
cross-reference is intentional (see step 5b)
|
||||
|
||||
## Version section template
|
||||
|
||||
@@ -443,12 +410,8 @@ if closed:
|
||||
|
||||
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See
|
||||
step 5b.
|
||||
|
||||
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
||||
|
||||
After all edits and cross-referencing are complete, generate a structured
|
||||
@@ -477,15 +440,9 @@ There are exactly two types:
|
||||
release, but the PR is being released elsewhere — the fix may not
|
||||
actually ship here.
|
||||
2. **PR is in the milestone, but the issue it closes is in a different
|
||||
milestone.** The PR is being released here, but the issue it fixes is
|
||||
being released in a different version — the changelog pairing is
|
||||
misleading.
|
||||
|
||||
**Exception — issue with no milestone is NOT an anomaly.** Milestones
|
||||
are only required for issues tracked in the "Main" project. A milestone
|
||||
PR that closes an issue with no milestone references an issue from
|
||||
another (probably private) project; that is expected and the issue is
|
||||
not part of this changelog. Do not report it.
|
||||
milestone (or has no milestone).** The PR is being released here, but
|
||||
the issue it fixes is being released in a different version (or never
|
||||
tracked in a milestone) — the changelog pairing is misleading.
|
||||
|
||||
**Anything else is not an anomaly.** Other discrepancies (exclusion
|
||||
labels on in-changelog issues, missing valid issues, unmerged PR
|
||||
@@ -641,10 +598,6 @@ for pr_num in sorted(changelog_prs):
|
||||
if get_pr_milestone(pr_num) != MILESTONE: continue
|
||||
for issue_num in pr.get('closing_issues', []):
|
||||
issue_ms = get_issue_milestone(issue_num)
|
||||
# No milestone = issue from another (probably private) project —
|
||||
# milestones are only required for the "Main" project. Not an
|
||||
# anomaly, and the issue never belongs in this changelog.
|
||||
if issue_ms is None: continue
|
||||
if issue_ms != MILESTONE:
|
||||
anomalies_b.append({
|
||||
'pr': pr_num,
|
||||
@@ -667,7 +620,7 @@ with open(OUTPUT, 'w') as f:
|
||||
|
||||
f.write('## Summary\n\n')
|
||||
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
|
||||
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
|
||||
f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n')
|
||||
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
|
||||
|
||||
# --- Anomalies section ---
|
||||
@@ -696,7 +649,7 @@ with open(OUTPUT, 'w') as f:
|
||||
f.write('\n')
|
||||
|
||||
if n_b:
|
||||
f.write(f'\n### PR in {MILESTONE}, closing issue in a different milestone\n\n')
|
||||
f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n')
|
||||
by_pr = {}
|
||||
for b in anomalies_b:
|
||||
by_pr.setdefault(b['pr'], []).append(b)
|
||||
@@ -731,11 +684,8 @@ milestone mismatches between issues and their referenced PRs:
|
||||
|
||||
1. **Issue in milestone, referenced PR in different milestone or no milestone** —
|
||||
the changelog claims a fix here, but the PR is released elsewhere.
|
||||
2. **PR in milestone, closing issue in a different milestone** —
|
||||
2. **PR in milestone, closing issue in different milestone or no milestone** —
|
||||
the PR is released here, but the issue it fixes belongs to another version.
|
||||
(An issue with *no* milestone belongs to another, probably private,
|
||||
project — milestones are only required on the "Main" project — so it is
|
||||
neither an anomaly nor a changelog candidate.)
|
||||
|
||||
**Rule violations are not in the report** — they are workflow errors the
|
||||
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
|
||||
@@ -782,14 +732,6 @@ self-contained and clickable in any Markdown viewer.
|
||||
Taiga description text or by searching GitHub PRs that reference the Taiga
|
||||
URL. Replace the Taiga reference with the GitHub issue link and add the PR
|
||||
reference if applicable.
|
||||
- **Security advisory (GHSA) entries.** Advisories fixed in the release are
|
||||
listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or
|
||||
PR link**, even though they are not in the milestone. The GHSA ID and
|
||||
description come from the user — do **not** fetch or verify the URL, and do
|
||||
not drop a draft (unpublished) advisory. Precedent:
|
||||
`- Fix arbitrary file read security issue on create-font-variant rpc method
|
||||
(https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`.
|
||||
See step 5b.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
@@ -812,11 +754,8 @@ self-contained and clickable in any Markdown viewer.
|
||||
- **Anomaly = milestone mismatch only.** The report contains only milestone
|
||||
mismatches: (1) the issue is in this milestone but the referenced PR is
|
||||
in a different milestone (or unassigned), and (2) the PR is in this
|
||||
milestone but the issue it closes is in a different milestone. An
|
||||
*unassigned* (milestone-less) issue closed by a milestone PR is **not**
|
||||
an anomaly: milestones are required only for the "Main" project, so such
|
||||
issues come from another (probably private) project and are not changelog
|
||||
candidates. These anomalies are reported because the changelog pairing is
|
||||
milestone but the issue it closes is in a different milestone (or
|
||||
unassigned). These are anomalies because the changelog pairing is
|
||||
*misleading* — the human needs to decide whether the milestone or the
|
||||
changelog is wrong. All other discrepancies (exclusion labels, missing
|
||||
valid issues, unmerged PR references, duplicates, stale milestone
|
||||
|
||||
@@ -5,8 +5,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
|
||||
## Focused memories
|
||||
|
||||
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
|
||||
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
|
||||
- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
|
||||
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
|
||||
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
|
||||
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
|
||||
|
||||
@@ -102,5 +101,10 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend test commands, coverage rules, and conventions: `mem:backend/testing`.
|
||||
Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
|
||||
|
||||
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
|
||||
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
|
||||
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
|
||||
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
## Storage and media
|
||||
|
||||
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
|
||||
- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`.
|
||||
- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes.
|
||||
- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows.
|
||||
- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code.
|
||||
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
|
||||
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
|
||||
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
|
||||
@@ -25,4 +28,4 @@
|
||||
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
|
||||
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
|
||||
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
|
||||
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
|
||||
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Backend Storage
|
||||
|
||||
## Abstraction
|
||||
|
||||
- `app.storage` stores binary objects.
|
||||
- Each object has a `storage_object` database row.
|
||||
- The row stores the UUID, size, backend, timestamps, and Transit metadata.
|
||||
- The backend stores the binary content.
|
||||
- Supported backends are `:fs` and `:s3`.
|
||||
- FS uses one root directory and a UUID-derived path.
|
||||
- S3 uses one configured bucket and an optional prefix.
|
||||
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
|
||||
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
|
||||
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
|
||||
- Deprecated asset-storage config keys remain supported for migration.
|
||||
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
|
||||
|
||||
## Object Lifecycle
|
||||
|
||||
- `put-object!` creates the database row before it writes backend content.
|
||||
- Backend content is written only when the row is new.
|
||||
- A failed backend write can leave an unreferenced database row.
|
||||
- Callers often set `:touched-at` so garbage collection can remove such rows.
|
||||
- `get-object` excludes rows with `deleted_at`.
|
||||
- Existing object values can remain readable until physical deletion.
|
||||
- `:expired-at` blocks reads after the expiration time.
|
||||
- `del-object!` sets `deleted_at`. It does not remove backend content.
|
||||
- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
|
||||
- `storage-gc-touched` finds references before it sets `deleted_at`.
|
||||
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
|
||||
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
|
||||
|
||||
## Deduplication
|
||||
|
||||
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
|
||||
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
|
||||
- The lookup does not include file ID, profile ID, team ID, or organization ID.
|
||||
- Objects can therefore share content across users and files within one bucket.
|
||||
- Deleted objects are not reused.
|
||||
- `tempfile` objects never use deduplication, even when the caller requests it.
|
||||
- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
|
||||
|
||||
## Bucket Rules
|
||||
|
||||
| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
|
||||
| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
|
||||
| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
|
||||
| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
|
||||
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
|
||||
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
|
||||
| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
|
||||
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
|
||||
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
|
||||
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
|
||||
|
||||
- The valid bucket set lives in `app.storage/valid-buckets`.
|
||||
- `file-media-object` is the default bucket for old rows without bucket metadata.
|
||||
- Do not assign a new bucket without adding its access and cleanup behavior.
|
||||
- The touched-object collector raises an internal error for an unknown bucket.
|
||||
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
|
||||
- It does not support `file-data-fragment` or `file-change`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- `app.http.assets` decides direct object authentication from the bucket.
|
||||
- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
|
||||
- Other valid buckets require a session or access-token profile ID.
|
||||
- File-media routes also require file read permission.
|
||||
- Non-public direct responses set `content-disposition: attachment`.
|
||||
- FS responses use `x-accel-redirect` for the configured asset path.
|
||||
- S3 responses use a presigned URL and an HTTP redirect.
|
||||
|
||||
## File Data
|
||||
|
||||
- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
|
||||
- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
|
||||
- `db` stores encoded data in `file_data.data`.
|
||||
- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
|
||||
- The `file_data.metadata.storage-ref-id` value points to the storage object.
|
||||
- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
|
||||
- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Backend Testing
|
||||
|
||||
JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`.
|
||||
|
||||
- READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs.
|
||||
- All CLI commands must be executed from the `backend/` subdirectory.
|
||||
- Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed.
|
||||
- Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
|
||||
- Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var.
|
||||
- Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas.
|
||||
- If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`.
|
||||
@@ -6,7 +6,6 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
|
||||
|
||||
- `penpotdev-infra`: shared `postgres`, `minio`, `minio-setup`, `mailer`, `ldap`. File: `docker-compose.infra.yml`.
|
||||
- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer.
|
||||
- Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount.
|
||||
- All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands.
|
||||
|
||||
## Source-of-truth files
|
||||
@@ -66,7 +65,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
|
||||
|
||||
## CLI surface
|
||||
|
||||
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX] [--opencode-config-dir DIR]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). `--opencode-config-dir DIR` bind-mounts DIR at `~/.config/opencode` in-container via the optional overlay above; mount applies at container creation, so changing it requires stop + re-run.
|
||||
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet).
|
||||
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra.
|
||||
- `run-devenv`: legacy alias, ws0 non-agentic attached.
|
||||
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
## Layout and commands
|
||||
|
||||
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
||||
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Exporter test conventions and CI: `mem:exporter/testing`.
|
||||
|
||||
## HTTP and browser pool
|
||||
|
||||
@@ -32,4 +31,4 @@
|
||||
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
|
||||
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
|
||||
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Exporter Testing
|
||||
|
||||
- READ `mem:testing` first.
|
||||
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
|
||||
- Register every test namespace in `exporter-tests.runner`.
|
||||
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
|
||||
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
|
||||
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
|
||||
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
|
||||
- For iterative focused runs, build once and reuse the compiled bundle.
|
||||
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
|
||||
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
|
||||
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
|
||||
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
|
||||
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
|
||||
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.
|
||||
@@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose
|
||||
|
||||
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
|
||||
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`.
|
||||
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`.
|
||||
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
|
||||
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
|
||||
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
|
||||
|
||||
@@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact
|
||||
## See also
|
||||
|
||||
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
|
||||
- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`.
|
||||
- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
|
||||
@@ -9,7 +9,6 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
|
||||
- Finding issues with no milestone.
|
||||
- Fetching PR details by number or by milestone.
|
||||
- Comparing milestone issues against CHANGES.md to find missing entries.
|
||||
- Listing or inspecting GitHub Security Advisories (GHSA).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -73,30 +72,6 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
|
||||
**Output**: JSON array to stdout; progress to stderr.
|
||||
|
||||
### `advisories`
|
||||
|
||||
List or inspect GitHub Security Advisories for the repository.
|
||||
|
||||
```bash
|
||||
# List all advisories (summary view)
|
||||
python3 scripts/gh.py advisories
|
||||
|
||||
# Filter by severity
|
||||
python3 scripts/gh.py advisories --severity critical
|
||||
|
||||
# Filter by state
|
||||
python3 scripts/gh.py advisories --state triage
|
||||
|
||||
# Get full detail for a single advisory
|
||||
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
|
||||
```
|
||||
|
||||
**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url.
|
||||
|
||||
**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps.
|
||||
|
||||
**Output**: JSON to stdout; progress to stderr.
|
||||
|
||||
## Key principles
|
||||
|
||||
- All output is JSON — pipe into `jq` or other tools for further processing.
|
||||
|
||||
@@ -13,7 +13,7 @@ and helpers, consult:
|
||||
builders, production-path change helpers
|
||||
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests,
|
||||
live browser verification via nREPL
|
||||
- `mem:backend/testing` — JVM `clojure.test` under `backend/test/`
|
||||
- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
|
||||
|
||||
## When to Use
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# Creating Pull Requests
|
||||
|
||||
PR only on explicit request.
|
||||
|
||||
## Branch Naming
|
||||
|
||||
- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`).
|
||||
- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
|
||||
- If the user already created the branch, use it as-is — never rename.
|
||||
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
|
||||
|
||||
## Target Branch
|
||||
|
||||
|
||||
@@ -34,36 +34,6 @@ Skipping this step is the #1 cause of incorrect or incomplete work.
|
||||
|
||||
---
|
||||
|
||||
## Auto-triggers
|
||||
|
||||
- **Security advisory URL pasted** — When the user pastes a URL matching
|
||||
`github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID
|
||||
from the URL and run `python3 scripts/gh.py advisories <GHSA-ID>` to fetch
|
||||
full advisory details before proceeding.
|
||||
- **Issue or PR mentioned** — When the user mentions a penpot/penpot issue or
|
||||
PR (URL like `github.com/penpot/penpot/issues/<n>` / `.../pull/<n>`, or a
|
||||
bare `#<n>` when context clearly refers to this repo), fetch details via CLI
|
||||
instead of WebFetch:
|
||||
- Issue → `gh issue view <n> --repo penpot/penpot` (add `--comments` when
|
||||
discussion context matters).
|
||||
- Single PR → `gh pr view <n> --repo penpot/penpot`.
|
||||
- Multiple PRs (list, file, or milestone) → `python3 scripts/gh.py prs ...`.
|
||||
Do this before proceeding. Only use WebFetch if the CLI fails.
|
||||
|
||||
## Writing Rules
|
||||
|
||||
Writing rules, from Orwell, 1946. These govern prose: docs, PR text, messages. Never touch code or technical terms; swap in everyday words only where precision survives.
|
||||
|
||||
1. Never use a metaphor, simile or other figure of speech which you are used to seeing in print.
|
||||
2. Never use a long word where a short one will do.
|
||||
3. If it is possible to cut a word out, always cut it out.
|
||||
4. Never use the passive where you can use the active.
|
||||
5. Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent.
|
||||
6. Break any of these rules sooner than say anything outright barbarous.
|
||||
Review every prose output against these rules before delivering.
|
||||
|
||||
---
|
||||
|
||||
# Memory system
|
||||
|
||||
Memories are the **primary project guidance** — not docs or readme files.
|
||||
@@ -143,5 +113,4 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
||||
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
|
||||
|
||||
+7
-132
@@ -11,154 +11,29 @@
|
||||
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
|
||||
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
|
||||
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
|
||||
- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
|
||||
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
|
||||
- Fix invalid formulas being accepted in numeric inputs (by @AKnassa) [#9581](https://github.com/penpot/penpot/issues/9581) (PR: [#10659](https://github.com/penpot/penpot/pull/10659))
|
||||
- Fix radial gradient handles blowing up in size when rotated on ellipses (by @AKnassa) [#10069](https://github.com/penpot/penpot/issues/10069) (PR: [#10666](https://github.com/penpot/penpot/pull/10666))
|
||||
- Fix plugin API validation errors being too generic to diagnose the failure (by @AKnassa) [#10072](https://github.com/penpot/penpot/issues/10072) (PR: [#10667](https://github.com/penpot/penpot/pull/10667))
|
||||
- Fix crash with referential integrity error when deleting a component inside a grid (by @Alotor) [#10101](https://github.com/penpot/penpot/issues/10101) (PR: [#10956](https://github.com/penpot/penpot/pull/10956))
|
||||
- Fix component copies not preserving rotation when the main component has changes [#10109](https://github.com/penpot/penpot/issues/10109) (PR: [#10574](https://github.com/penpot/penpot/pull/10574))
|
||||
- Fix text width and height staying stale after setting growType in the plugin API [#10207](https://github.com/penpot/penpot/issues/10207) (PR: [#9898](https://github.com/penpot/penpot/pull/9898))
|
||||
- Fix padding not painted until expanding the 4-sides padding option [#10278](https://github.com/penpot/penpot/issues/10278) (PR: [#10602](https://github.com/penpot/penpot/pull/10602))
|
||||
- Fix files with custom fonts breaking with a referential integrity error when moved between teams (by @filipsajdak) [#10496](https://github.com/penpot/penpot/issues/10496) (PR: [#10837](https://github.com/penpot/penpot/pull/10837))
|
||||
- Fix clicking overlapping comment bubbles zooming to 20000% without showing the comments [#10526](https://github.com/penpot/penpot/issues/10526) (PR: [#10543](https://github.com/penpot/penpot/pull/10543))
|
||||
- Fix user menu subsections in the dashboard not closing when hovering away from the parent option (by @AKnassa) [#10549](https://github.com/penpot/penpot/issues/10549) (PR: [#10639](https://github.com/penpot/penpot/pull/10639))
|
||||
- Fix self-hosted env-generated config.js being cached for 7 days so PENPOT_FLAGS changes did not reach already-cached browsers (by @filipsajdak) [#10556](https://github.com/penpot/penpot/issues/10556) (PR: [#11146](https://github.com/penpot/penpot/pull/11146))
|
||||
- Fix color of selected text in light theme [#10570](https://github.com/penpot/penpot/issues/10570) (PR: [#10614](https://github.com/penpot/penpot/pull/10614))
|
||||
- Fix margin input order being inconsistent with padding inputs and between collapsed and expanded states [#10578](https://github.com/penpot/penpot/issues/10578) (PR: [#10797](https://github.com/penpot/penpot/pull/10797))
|
||||
- Fix uncaught DOMException when writing image/svg+xml content to the clipboard (by @AKnassa) [#10596](https://github.com/penpot/penpot/issues/10596) (PR: [#10663](https://github.com/penpot/penpot/pull/10663))
|
||||
- Fix tick icons not aligned in the font selector [#10597](https://github.com/penpot/penpot/issues/10597) (PR: [#10774](https://github.com/penpot/penpot/pull/10774))
|
||||
- Fix incorrect padding values when multiple shapes are selected [#10598](https://github.com/penpot/penpot/issues/10598) (PR: [#10602](https://github.com/penpot/penpot/pull/10602))
|
||||
- Fix integrity errors related to variants not being repaired [#10606](https://github.com/penpot/penpot/issues/10606) (PR: [#10768](https://github.com/penpot/penpot/pull/10768))
|
||||
- Fix changing password showing 'Password should be at least 8 characters' error on the old password field (by @AKnassa) [#10626](https://github.com/penpot/penpot/issues/10626) (PR: [#10661](https://github.com/penpot/penpot/pull/10661))
|
||||
- Fix stroke caps disappearing when dragging [#10633](https://github.com/penpot/penpot/issues/10633) (PR: [#10634](https://github.com/penpot/penpot/pull/10634))
|
||||
- Fix layout padding being saved as string after invalid input in multi-selection, causing persistence errors (by @niwinz) [#10638](https://github.com/penpot/penpot/issues/10638) (PR: [#10758](https://github.com/penpot/penpot/pull/10758))
|
||||
- Fix inconsistent theme handling between Penpot and plugins [#10676](https://github.com/penpot/penpot/issues/10676) (PR: [#10677](https://github.com/penpot/penpot/pull/10677))
|
||||
- Fix image stroke (strokeImage) support missing in the plugin API Stroke interface [#10682](https://github.com/penpot/penpot/issues/10682) (PR: [#10683](https://github.com/penpot/penpot/pull/10683))
|
||||
- Fix SVG images not working as fill in the WebGL renderer [#10705](https://github.com/penpot/penpot/issues/10705) (PR: [#10707](https://github.com/penpot/penpot/pull/10707))
|
||||
- Fix background blur not working on text shapes [#10706](https://github.com/penpot/penpot/issues/10706) (PR: [#10712](https://github.com/penpot/penpot/pull/10712))
|
||||
- Fix background blur not applying on strokes [#10713](https://github.com/penpot/penpot/issues/10713) (PR: [#10716](https://github.com/penpot/penpot/pull/10716))
|
||||
- Fix text shape with empty content breaking workspace updates [#10725](https://github.com/penpot/penpot/issues/10725) (PR: [#10731](https://github.com/penpot/penpot/pull/10731))
|
||||
- Fix missing SVG option in the file filters when adding an image fill (by @LuBoys) [#10756](https://github.com/penpot/penpot/issues/10756) (PR: [#10771](https://github.com/penpot/penpot/pull/10771))
|
||||
- Update onboarding image [#10779](https://github.com/penpot/penpot/issues/10779) (PR: [#10783](https://github.com/penpot/penpot/pull/10783))
|
||||
- Fix main toolbar overlapping the grid edition bar [#10788](https://github.com/penpot/penpot/issues/10788) (PR: [#10789](https://github.com/penpot/penpot/pull/10789))
|
||||
- Fix WASM renderer panic when the WebGL context is restored mid-reload [#10810](https://github.com/penpot/penpot/issues/10810) (PR: [#10824](https://github.com/penpot/penpot/pull/10824))
|
||||
- Fix nginx frontend forwarding the client Host header to backend/exporter, breaking Istio strict mTLS routing (by @yamila-moreno) [#10835](https://github.com/penpot/penpot/issues/10835) (PR: [#11233](https://github.com/penpot/penpot/pull/11233))
|
||||
- Fix tutorial templates with components causing errors [#10839](https://github.com/penpot/penpot/issues/10839)
|
||||
- Fix plugin 'Try out' flow crashing when projects have not loaded yet [#10858](https://github.com/penpot/penpot/issues/10858) (PR: [#10859](https://github.com/penpot/penpot/pull/10859))
|
||||
- Fix collapsed Fill color section on the design panel for new texts [#10860](https://github.com/penpot/penpot/issues/10860) (PR: [#10972](https://github.com/penpot/penpot/pull/10972))
|
||||
- Fix grid item date tooltip in the project view showing 'Will be deleted' instead of creation date (by @0xTHAC0) [#10873](https://github.com/penpot/penpot/issues/10873) (PR: [#11161](https://github.com/penpot/penpot/pull/11161))
|
||||
- Merge stop and start measurement shortcut to match current behavior [#10884](https://github.com/penpot/penpot/issues/10884) (PR: [#10906](https://github.com/penpot/penpot/pull/10906))
|
||||
- Fix shape size badge displayed twice when a user with Viewer permissions selects a shape [#10893](https://github.com/penpot/penpot/issues/10893) (PR: [#10985](https://github.com/penpot/penpot/pull/10985))
|
||||
- Fix main menu being covered by the toolbar [#10902](https://github.com/penpot/penpot/issues/10902) (PR: [#10926](https://github.com/penpot/penpot/pull/10926))
|
||||
- Fix font family typography asset persisting across files in newly created text layers [#10925](https://github.com/penpot/penpot/issues/10925) (PR: [#11134](https://github.com/penpot/penpot/pull/11134))
|
||||
- Fix error raised when editing justified text [#10944](https://github.com/penpot/penpot/issues/10944) (PR: [#10945](https://github.com/penpot/penpot/pull/10945))
|
||||
- Fix MCP WebSocket proxy failing after penpot-mcp container restarts due to stale nginx DNS resolution (by @780Farva) [#10946](https://github.com/penpot/penpot/issues/10946) (PR: [#10947](https://github.com/penpot/penpot/pull/10947))
|
||||
- Fix verification email address being unreadable due to low-contrast text on the register success page [#10950](https://github.com/penpot/penpot/issues/10950) (PR: [#10965](https://github.com/penpot/penpot/pull/10965))
|
||||
- Fix image swatches displaying a wrong format in the color picker list view [#10951](https://github.com/penpot/penpot/issues/10951) (PR: [#10975](https://github.com/penpot/penpot/pull/10975))
|
||||
- Fix text editor crashing when dropping dragged text after selecting all content [#10954](https://github.com/penpot/penpot/issues/10954) (PR: [#10959](https://github.com/penpot/penpot/pull/10959))
|
||||
- Fix MCP tokens being usable as API access tokens [#10960](https://github.com/penpot/penpot/issues/10960) (PR: [#10962](https://github.com/penpot/penpot/pull/10962))
|
||||
- Add size limit and rate limiting to the send-user-feedback endpoint [#10979](https://github.com/penpot/penpot/issues/10979) (PR: [#10990](https://github.com/penpot/penpot/pull/10990))
|
||||
- Fix main menu not keeping alignment when the left sidebar is expanded [#10981](https://github.com/penpot/penpot/issues/10981) (PR: [#10986](https://github.com/penpot/penpot/pull/10986))
|
||||
- Fix update-profile-props RPC method accepting undocumented keys [#10991](https://github.com/penpot/penpot/issues/10991) (PR: [#10992](https://github.com/penpot/penpot/pull/10992))
|
||||
- Fix import-binfile RPC method schema accepting a file-id parameter [#10993](https://github.com/penpot/penpot/issues/10993) (PR: [#10994](https://github.com/penpot/penpot/pull/10994))
|
||||
- Fix assemble-chunks session lookup ignoring the profile-id scope [#11011](https://github.com/penpot/penpot/issues/11011) (PR: [#11012](https://github.com/penpot/penpot/pull/11012))
|
||||
- Validate font-id team ownership in create-font-variant [#11013](https://github.com/penpot/penpot/issues/11013) (PR: [#11014](https://github.com/penpot/penpot/pull/11014))
|
||||
- Validate team ownership on file library link endpoints [#11015](https://github.com/penpot/penpot/issues/11015) (PR: [#11016](https://github.com/penpot/penpot/pull/11016))
|
||||
- Limit object size allocation in the V1 binfile parser [#11017](https://github.com/penpot/penpot/issues/11017) (PR: [#11018](https://github.com/penpot/penpot/pull/11018))
|
||||
- Limit recursion depth in the Fressian reader [#11019](https://github.com/penpot/penpot/issues/11019) (PR: [#11020](https://github.com/penpot/penpot/pull/11020))
|
||||
- Limit concurrent imports in the import-binfile RPC method [#11023](https://github.com/penpot/penpot/issues/11023) (PR: [#11024](https://github.com/penpot/penpot/pull/11024))
|
||||
- Validate content-type on management upload endpoints [#11025](https://github.com/penpot/penpot/issues/11025) (PR: [#11026](https://github.com/penpot/penpot/pull/11026))
|
||||
- Fix webhook endpoints allowing unauthorized access via creator-id fallback [#11028](https://github.com/penpot/penpot/issues/11028) (PR: [#11029](https://github.com/penpot/penpot/pull/11029))
|
||||
- Escape markdown in user-controlled fields of Mattermost error notifications [#11033](https://github.com/penpot/penpot/issues/11033) (PR: [#11034](https://github.com/penpot/penpot/pull/11034))
|
||||
- Enforce file read permission check on asset endpoints [#11035](https://github.com/penpot/penpot/issues/11035) (PR: [#11036](https://github.com/penpot/penpot/pull/11036))
|
||||
- Add accumulated storage byte quota for media uploads [#11037](https://github.com/penpot/penpot/issues/11037) (PR: [#11038](https://github.com/penpot/penpot/pull/11038))
|
||||
- Add bounding box dimension limit to exports [#11041](https://github.com/penpot/penpot/issues/11041) (PR: [#11042](https://github.com/penpot/penpot/pull/11042))
|
||||
- Sanitize embedded scripts in SVG uploads [#11043](https://github.com/penpot/penpot/issues/11043) (PR: [#11044](https://github.com/penpot/penpot/pull/11044))
|
||||
- Fix duplicate file ID returning inconsistent error responses [#11045](https://github.com/penpot/penpot/issues/11045) (PR: [#11050](https://github.com/penpot/penpot/pull/11050))
|
||||
- Enforce permission checks in WebSocket subscription handlers [#11052](https://github.com/penpot/penpot/issues/11052) (PR: [#11054](https://github.com/penpot/penpot/pull/11054))
|
||||
- Fix 'something went wrong' popup when using incremental numerical input interaction [#11053](https://github.com/penpot/penpot/issues/11053) (PR: [#10794](https://github.com/penpot/penpot/pull/10794))
|
||||
- Enforce password complexity validation on the backend [#11055](https://github.com/penpot/penpot/issues/11055) (PR: [#11059](https://github.com/penpot/penpot/pull/11059))
|
||||
- Normalize string inputs before processing [#11060](https://github.com/penpot/penpot/issues/11060) (PR: [#11061](https://github.com/penpot/penpot/pull/11061))
|
||||
- Add cooldown to avoid sending duplicate invitation emails [#11062](https://github.com/penpot/penpot/issues/11062) (PR: [#11063](https://github.com/penpot/penpot/pull/11063))
|
||||
- Enable SSRF protection for organization SSO validation [#11064](https://github.com/penpot/penpot/issues/11064) (PR: [#11065](https://github.com/penpot/penpot/pull/11065))
|
||||
- Fix clone-file-media-object allowing to clone media objects from files without read access [#11087](https://github.com/penpot/penpot/issues/11087) (PR: [#11090](https://github.com/penpot/penpot/pull/11090))
|
||||
- Fix 404 error page logo not visible in dark mode [#11091](https://github.com/penpot/penpot/issues/11091) (PR: [#11167](https://github.com/penpot/penpot/pull/11167))
|
||||
- Fix incorrect permission handling when creating an invitation [#11098](https://github.com/penpot/penpot/issues/11098) (PR: [#11099](https://github.com/penpot/penpot/pull/11099))
|
||||
- Reject zero or negative total-chunks values in upload sessions [#11103](https://github.com/penpot/penpot/issues/11103) (PR: [#11104](https://github.com/penpot/penpot/pull/11104))
|
||||
- Fix import-binfile accepting unsupported version values without validation [#11105](https://github.com/penpot/penpot/issues/11105) (PR: [#11107](https://github.com/penpot/penpot/pull/11107))
|
||||
- Fix sessions remaining active on other devices after account deletion [#11114](https://github.com/penpot/penpot/issues/11114) (PR: [#11115](https://github.com/penpot/penpot/pull/11115))
|
||||
- Use random UUIDs for share link IDs instead of a predictable scheme [#11116](https://github.com/penpot/penpot/issues/11116) (PR: [#11117](https://github.com/penpot/penpot/pull/11117))
|
||||
- Fix plugin manifest fetch hanging indefinitely without timeout [#11119](https://github.com/penpot/penpot/issues/11119) (PR: [#11120](https://github.com/penpot/penpot/pull/11120))
|
||||
- Use constant-time comparison for shared key authentication [#11121](https://github.com/penpot/penpot/issues/11121) (PR: [#11122](https://github.com/penpot/penpot/pull/11122))
|
||||
- Fix ESC key not closing the comment input box after posting a comment in the workspace [#11128](https://github.com/penpot/penpot/issues/11128) (PR: [#11131](https://github.com/penpot/penpot/pull/11131))
|
||||
- Fix token edit modal crashing when resolving tokens with group nodes [#11143](https://github.com/penpot/penpot/issues/11143) (PR: [#11144](https://github.com/penpot/penpot/pull/11144))
|
||||
- Fix text editor crashing when pasting into an empty text shape [#11149](https://github.com/penpot/penpot/issues/11149) (PR: [#11150](https://github.com/penpot/penpot/pull/11150))
|
||||
- Fix comment avatars appearing on top of rulers when scrolling the canvas (by @filipsajdak) [#11163](https://github.com/penpot/penpot/issues/11163) (PR: [#11168](https://github.com/penpot/penpot/pull/11168))
|
||||
- Fix infinite loop of get-teams and get-team-members calls when granting team access from an email link [#11215](https://github.com/penpot/penpot/issues/11215) (PR: [#11223](https://github.com/penpot/penpot/pull/11223))
|
||||
- Fix RPC requests bypassing rate limiting with fractional bucket refill intervals [#11253](https://github.com/penpot/penpot/issues/11253) (PR: [#11254](https://github.com/penpot/penpot/pull/11254))
|
||||
- Fix tempfile bucket serving objects to any authenticated user instead of only the uploader [#11269](https://github.com/penpot/penpot/issues/11269) (PR: [#11270](https://github.com/penpot/penpot/pull/11270))
|
||||
- Fix increasing a value by clicking and dragging in a numeric input [#11274](https://github.com/penpot/penpot/issues/11274) (PR: [#11334](https://github.com/penpot/penpot/pull/11334))
|
||||
- Fix notification pill rendering unescaped HTML in the detail section when importing tokens [#11276](https://github.com/penpot/penpot/issues/11276) (PR: [#11275](https://github.com/penpot/penpot/pull/11275))
|
||||
- Fix share-link holders reading pages outside the authorized scope via the get-page RPC command [#11281](https://github.com/penpot/penpot/issues/11281) (PR: [#11284](https://github.com/penpot/penpot/pull/11284))
|
||||
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
|
||||
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
|
||||
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
|
||||
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
|
||||
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
|
||||
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
|
||||
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
|
||||
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
|
||||
- Highlight the first matching font in the font list when searching (by @ai-mountain) [#3204](https://github.com/penpot/penpot/issues/3204) (PR: [#9512](https://github.com/penpot/penpot/pull/9512), [#10450](https://github.com/penpot/penpot/pull/10450))
|
||||
- Preserve token references when copying and pasting properties instead of resolving them to values (by @AKnassa) [#9582](https://github.com/penpot/penpot/issues/9582) (PR: [#10665](https://github.com/penpot/penpot/pull/10665))
|
||||
- Add waitForLayoutUpdate method to the plugin API [#10136](https://github.com/penpot/penpot/issues/10136) (PR: [#9898](https://github.com/penpot/penpot/pull/9898))
|
||||
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
|
||||
- Simplify MCP server configuration for common MCP clients [#10355](https://github.com/penpot/penpot/issues/10355) (PR: [#10604](https://github.com/penpot/penpot/pull/10604))
|
||||
- Remove misleading MCP client JSON snippet from the key-generated modal (by @Shlok1729) [#10399](https://github.com/penpot/penpot/issues/10399) (PR: [#10415](https://github.com/penpot/penpot/pull/10415))
|
||||
- Preview font families in the font selector [#10403](https://github.com/penpot/penpot/issues/10403) (PR: [#10411](https://github.com/penpot/penpot/pull/10411))
|
||||
- Remember expanded/collapsed state of token sets in the color tokens picker (session scope) [#10551](https://github.com/penpot/penpot/issues/10551) (PR: [#10864](https://github.com/penpot/penpot/pull/10864))
|
||||
- Show token sets in reverse order by default in the color tokens picker (by @rhinocap) [#10552](https://github.com/penpot/penpot/issues/10552) (PR: [#10658](https://github.com/penpot/penpot/pull/10658))
|
||||
- Add multi-selection and bulk delete support to pages in the workspace sitemap [#10580](https://github.com/penpot/penpot/issues/10580) (PR: [#10581](https://github.com/penpot/penpot/pull/10581))
|
||||
- Add a grid/list view toggle for files in the dashboard [#10691](https://github.com/penpot/penpot/issues/10691) (PR: [#10692](https://github.com/penpot/penpot/pull/10692))
|
||||
- Migrate Docker images to Docker Hardened Images (DHI) [#10720](https://github.com/penpot/penpot/issues/10720) (PR: [#10732](https://github.com/penpot/penpot/pull/10732), [#10733](https://github.com/penpot/penpot/pull/10733), [#10734](https://github.com/penpot/penpot/pull/10734))
|
||||
- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675))
|
||||
- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964))
|
||||
- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138))
|
||||
## 2.17.2
|
||||
|
||||
|
||||
## 2.17.1 (Unreleased)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272))
|
||||
- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366))
|
||||
- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86)
|
||||
|
||||
## 2.17.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
|
||||
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
|
||||
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
|
||||
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
|
||||
- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
|
||||
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
|
||||
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
|
||||
- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715))
|
||||
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
|
||||
- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
|
||||
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
|
||||
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
|
||||
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
|
||||
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
|
||||
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
|
||||
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
|
||||
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
|
||||
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
|
||||
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
|
||||
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
|
||||
## 2.17.0
|
||||
|
||||
@@ -3136,7 +3011,7 @@ is a number of cores)
|
||||
- Enable penpot SVG metadata only when exporting complete files [Taiga #1914](https://tree.taiga.io/project/penpot/us/1914?milestone=295883)
|
||||
- Export to PDF all artboards of one page [Taiga #1895](https://tree.taiga.io/project/penpot/us/1895)
|
||||
- Go to a undo step clicking on a history element of the list [Taiga #1374](https://tree.taiga.io/project/penpot/us/1374)
|
||||
- Increment font size by 10 with shift+arrows [#1047](https://github.com/penpot/penpot/issues/1047)
|
||||
- Increment font size by 10 with shift+arrows [1047](https://github.com/penpot/penpot/issues/1047)
|
||||
- New shortcut to detach components Ctrl+Shift+K [Taiga #1799](https://tree.taiga.io/project/penpot/us/1799)
|
||||
- Set email inputs to type "email", to aid keyboard entry [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921)
|
||||
- Use shift+move to move element orthogonally [#823](https://github.com/penpot/penpot/issues/823)
|
||||
|
||||
@@ -14,7 +14,6 @@ Center](https://help.penpot.app/).
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Workflow](#workflow)
|
||||
- [Branch naming](#branch-naming)
|
||||
- [Format](#format)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
@@ -74,18 +73,6 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
4. **Format and lint** — run the checks described in
|
||||
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Branch names are not enforced, but we recommend the following:
|
||||
|
||||
- **`issue-NNNN`** — when working from a GitHub issue, name the branch after
|
||||
it (e.g. `issue-11525`). This makes each PR's origin self-evident.
|
||||
- Otherwise, use a short, descriptive name with words separated by hyphens
|
||||
and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
|
||||
|
||||
Since PRs are squash-merged, the branch name does not survive into the
|
||||
commit history — what matters is the [PR title](#title-format).
|
||||
|
||||
### Format
|
||||
|
||||
#### Title
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# HIGHLIGHTS
|
||||
|
||||
## 2.17.0
|
||||
|
||||
- Background blur is here
|
||||
- WebGL rendering gets stronger
|
||||
- MCP connection status and more
|
||||
- Design tokens: more visible, more user-friendly
|
||||
|
||||
|
||||
## 2.16.0
|
||||
|
||||
- Design tokens in the design panel
|
||||
- Major community contributions
|
||||
- WebGL rendering (beta)
|
||||
|
||||
|
||||
## 2.15.0
|
||||
|
||||
- AI connected to real design context
|
||||
- Multi-directional workflow
|
||||
- Your stack, your model, your decision
|
||||
|
||||
|
||||
|
||||
|
||||
+10
-10
@@ -17,7 +17,7 @@
|
||||
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
|
||||
|
||||
io.lettuce/lettuce-core {:mvn/version "7.7.0.RELEASE"}
|
||||
io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"}
|
||||
;; Minimal dependencies required by lettuce, we need to include them
|
||||
;; explicitly because clojure dependency management does not support
|
||||
;; yet the BOM format.
|
||||
@@ -25,7 +25,7 @@
|
||||
io.micrometer/micrometer-observation {:mvn/version "1.14.2"}
|
||||
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
com.google.guava/guava {:mvn/version "33.7.1-jre"}
|
||||
com.google.guava/guava {:mvn/version "33.6.0-jre"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v11.10"
|
||||
@@ -40,32 +40,32 @@
|
||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.13"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.4.0"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
|
||||
|
||||
io.whitfin/siphash {:mvn/version "3.0.0"}
|
||||
io.whitfin/siphash {:mvn/version "2.0.0"}
|
||||
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
org.passay/passay {:mvn/version "2.0.0"}
|
||||
org.passay/passay {:mvn/version "1.6.6"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.23.2"}
|
||||
org.jsoup/jsoup {:mvn/version "1.23.1"}
|
||||
|
||||
at.yawk.lz4/lz4-java
|
||||
{:mvn/version "1.11.2"}
|
||||
{:mvn/version "1.11.1"}
|
||||
|
||||
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
|
||||
|
||||
dawran6/emoji {:mvn/version "0.2.0"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.12.9"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.12.8"}
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.54.5"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.54.5"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.50.1"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
file named “{{file-name|abbreviate:25}}”.
|
||||
</p>
|
||||
<p>
|
||||
Since this file is in your Personal Projects, you can provide access by sending a view-only link.
|
||||
Since this file is in your Penpot team, you can provide access by sending a view-only link.
|
||||
This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||
</p>
|
||||
<p>To proceed, please click the button below to generate and send the view-only link:</p>
|
||||
|
||||
@@ -2,7 +2,7 @@ Hello!
|
||||
|
||||
{{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”.
|
||||
|
||||
Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||
Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.
|
||||
|
||||
To proceed, please click the link below to generate and send the view-only link:
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
“{{file-name|abbreviate:25}}”.
|
||||
</p>
|
||||
<p>
|
||||
Please note that the file is currently in Personal Projects, so direct access cannot be
|
||||
Please note that the file is currently in Your Penpot 's team, so direct access cannot be
|
||||
granted. However, you have two options to provide the requested access:
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
@@ -5,7 +5,7 @@ Hello!
|
||||
|
||||
{{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”.
|
||||
|
||||
Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access:
|
||||
Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access:
|
||||
|
||||
- Move the File to Another Team:
|
||||
|
||||
|
||||
@@ -190,20 +190,6 @@ Debug Main Page
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Validate file:</legend>
|
||||
<desc>Given an FILE-ID, check the referential integrity.</desc>
|
||||
<form method="get" action="/dbg/actions/file-validate">
|
||||
<div class="row">
|
||||
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" name="validate" value="Validate" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
</section>
|
||||
<section class="widget">
|
||||
<fieldset>
|
||||
@@ -236,7 +222,6 @@ Debug Main Page
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Import binfile:</legend>
|
||||
<desc>Import penpot file in binary format.</desc>
|
||||
@@ -251,34 +236,6 @@ Debug Main Page
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Repair file:</legend>
|
||||
<desc>Given an FILE-ID, repair the referential integrity errors.
|
||||
<br/>
|
||||
<br/>
|
||||
<b>WARNING: the reparation is not guaranteed and may cause loss of data!</b>
|
||||
<br/>
|
||||
<br/>
|
||||
You may need to give several repair rounds until all errors are cleared.
|
||||
</desc>
|
||||
<form method="get" action="/dbg/actions/file-repair">
|
||||
<div class="row">
|
||||
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="check-snapshot">Skip snapshot</label>
|
||||
<input id="check-snapshot" type="checkbox" name="skip-snapshot" />
|
||||
<br />
|
||||
<small>
|
||||
A snapshot is made just before the validation, unless skipped.
|
||||
</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" name="repair" value="Repair" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -45,10 +45,4 @@
|
||||
{:permits 4}
|
||||
|
||||
:send-user-feedback/by-profile
|
||||
{:permits 1 :queue 3}
|
||||
|
||||
:import-binfile/global
|
||||
{:permits 4}
|
||||
|
||||
:import-binfile/by-profile
|
||||
{:permits 1 :queue 2}}
|
||||
{:permits 1 :queue 3}}
|
||||
@@ -1,308 +1,11 @@
|
||||
;; Example rlimit.edn file
|
||||
^{:refresh "30s"}
|
||||
{:default
|
||||
[[:default :window "200000/h"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Auth & Identity — public, unauthenticated
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/login-with-password}
|
||||
[[:auth-password :bucket "100/50/1m"]]
|
||||
;; #{:main/get-teams}
|
||||
;; [[:burst :bucket "5/5/5s"]]
|
||||
|
||||
#{:main/login-with-ldap}
|
||||
[[:auth-ldap :bucket "20/10/5m"]]
|
||||
|
||||
#{:main/register-profile}
|
||||
[[:auth-register :bucket "20/10/15m"]]
|
||||
|
||||
#{:main/request-profile-recovery
|
||||
:main/prepare-register-profile}
|
||||
[[:auth-recovery :bucket "100/50/5m"]]
|
||||
|
||||
#{:main/recover-profile
|
||||
:main/verify-token}
|
||||
[[:auth-token :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; SSRF vectors — URL fetch endpoints
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-file-media-object-from-url}
|
||||
[[:url-fetch :bucket "100/50/5m"]]
|
||||
|
||||
#{:main/create-webhook
|
||||
:main/update-webhook}
|
||||
[[:webhook-validation :bucket "20/10/5m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Search — full sequential scan risk
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/search-files}
|
||||
[[:search :bucket "60/30/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Feedback & Invitations — email-sending
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/send-user-feedback
|
||||
:main/create-team-invitations}
|
||||
[[:email-send :bucket "30/15/5m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Media & File heavy ops
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/upload-file-media-object}
|
||||
[[:image-upload :bucket "200/100/1m"]]
|
||||
|
||||
#{:main/create-file-object-thumbnail
|
||||
:main/delete-file-object-thumbnails
|
||||
:main/get-file-object-thumbnails}
|
||||
[[:thumbnail-ops :bucket "5000/3000/1m"]]
|
||||
|
||||
#{:main/get-file-data-for-thumbnail
|
||||
:main/create-file-thumbnail}
|
||||
[[:thumbnail-data :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; UI navigation reads — high frequency
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/get-teams}
|
||||
[[:get-teams :bucket "5000/2500/30s"]]
|
||||
|
||||
#{:main/get-team-members}
|
||||
[[:get-team-members :bucket "4000/2000/30s"]]
|
||||
|
||||
#{:main/get-profile}
|
||||
[[:get-profile :bucket "500/250/30s"]]
|
||||
|
||||
#{:main/get-font-variants}
|
||||
[[:get-font-variants :bucket "250/125/30s"]]
|
||||
|
||||
#{:main/get-comment-threads}
|
||||
[[:get-comment-threads :bucket "500/250/30s"]]
|
||||
|
||||
#{:main/get-profiles-for-file-comments}
|
||||
[[:get-profiles-for-file-comments :bucket "300/150/30s"]]
|
||||
|
||||
#{:main/get-file-libraries}
|
||||
[[:get-file-libraries :bucket "200/100/30s"]]
|
||||
|
||||
#{:main/get-projects}
|
||||
[[:get-projects :bucket "120/60/30s"]]
|
||||
|
||||
#{:main/get-team-recent-files
|
||||
:main/get-unread-comment-threads}
|
||||
[[:get-team-recent :bucket "120/60/30s"]]
|
||||
|
||||
#{:main/get-page}
|
||||
[[:get-page :bucket "150/75/30s"]]
|
||||
|
||||
#{:main/get-access-tokens
|
||||
:main/get-subscription-usage}
|
||||
[[:get-access-tokens :bucket "150/75/30s"]]
|
||||
|
||||
#{:main/get-enabled-flags}
|
||||
[[:get-enabled-flags :bucket "250/125/30s"]]
|
||||
|
||||
#{:main/get-builtin-templates}
|
||||
[[:get-builtin-templates :bucket "200/100/30s"]]
|
||||
|
||||
#{:main/get-project
|
||||
:main/get-project-files}
|
||||
[[:get-project-info :bucket "80/40/30s"]]
|
||||
|
||||
#{:main/get-file}
|
||||
[[:get-file :bucket "180/90/1m"]]
|
||||
|
||||
#{:main/get-team-shared-files
|
||||
:main/get-team-info
|
||||
:main/get-team-users
|
||||
:main/get-team-invitations
|
||||
:main/get-team-deleted-files
|
||||
:main/get-sso-provider}
|
||||
[[:get-team-info :bucket "60/30/30s"]]
|
||||
|
||||
#{:main/get-comments
|
||||
:main/get-file-snapshots
|
||||
:main/get-library-usage
|
||||
:main/has-file-libraries}
|
||||
[[:get-misc-list :bucket "300/150/30s"]]
|
||||
|
||||
#{:main/get-comment-thread
|
||||
:main/get-library-file-references}
|
||||
[[:get-misc-single :bucket "60/30/30s"]]
|
||||
|
||||
#{:main/get-file-info
|
||||
:main/get-view-only-bundle
|
||||
:main/get-all-projects
|
||||
:main/get-owned-teams
|
||||
:main/get-team-stats
|
||||
:main/get-file-summary
|
||||
:main/get-file-stats
|
||||
:main/get-file-fragment}
|
||||
[[:get-light :bucket "60/30/30s"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; File mutations — editing active
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/update-file}
|
||||
[[:update-file :bucket "1000/500/1m"]]
|
||||
|
||||
#{:main/create-file
|
||||
:main/rename-file
|
||||
:main/duplicate-file
|
||||
:main/move-files}
|
||||
[[:file-create :bucket "60/30/1m"]]
|
||||
|
||||
#{:main/delete-file}
|
||||
[[:file-delete :bucket "80/40/1m"]]
|
||||
|
||||
#{:main/set-file-shared
|
||||
:main/update-file-library-sync-status
|
||||
:main/ignore-file-library-sync-status
|
||||
:main/link-file-to-library
|
||||
:main/unlink-file-from-library
|
||||
:main/create-file-snapshot
|
||||
:main/restore-file-snapshot
|
||||
:main/update-file-snapshot
|
||||
:main/delete-file-snapshot
|
||||
:main/lock-file-snapshot
|
||||
:main/unlock-file-snapshot}
|
||||
[[:file-mutations :bucket "80/40/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Project mutations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-project}
|
||||
[[:project-create :bucket "100/50/1m"]]
|
||||
|
||||
#{:main/delete-project
|
||||
:main/rename-project
|
||||
:main/duplicate-project
|
||||
:main/move-project
|
||||
:main/update-project-pin}
|
||||
[[:project-mutations :bucket "40/20/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Team mutations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-team
|
||||
:main/update-team
|
||||
:main/delete-team
|
||||
:main/update-team-photo
|
||||
:main/update-team-member-role
|
||||
:main/delete-team-member
|
||||
:main/leave-team
|
||||
:main/create-team-with-invitations
|
||||
:main/create-team-access-request
|
||||
:main/permanently-delete-team-files
|
||||
:main/restore-deleted-team-files}
|
||||
[[:team-mutations :bucket "60/30/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Comment operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-comment-thread
|
||||
:main/create-comment
|
||||
:main/update-comment
|
||||
:main/delete-comment
|
||||
:main/mark-all-threads-as-read}
|
||||
[[:comment-basic :bucket "30/15/1m"]]
|
||||
|
||||
#{:main/update-comment-thread
|
||||
:main/update-comment-thread-status
|
||||
:main/update-comment-thread-position
|
||||
:main/update-comment-thread-frame
|
||||
:main/delete-comment-thread}
|
||||
[[:comment-thread :bucket "80/40/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Profile operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/update-profile
|
||||
:main/update-profile-props
|
||||
:main/update-profile-photo
|
||||
:main/update-profile-password
|
||||
:main/update-profile-notifications
|
||||
:main/delete-profile
|
||||
:main/delete-profile-photo
|
||||
:main/request-email-change}
|
||||
[[:profile-mutations :bucket "30/15/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Font operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-font-variant
|
||||
:main/delete-font
|
||||
:main/delete-font-variant
|
||||
:main/update-font
|
||||
:main/download-font
|
||||
:main/download-font-family}
|
||||
[[:font-ops :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Access tokens
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-access-token
|
||||
:main/delete-access-token}
|
||||
[[:access-token :bucket "60/30/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Export / Import
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/export-binfile
|
||||
:main/import-binfile
|
||||
:main/clone-template}
|
||||
[[:export-import :bucket "80/40/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Upload sessions
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-upload-session
|
||||
:main/upload-chunk
|
||||
:main/assemble-file-media-object}
|
||||
[[:upload-session :bucket "100/50/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Webhooks
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/get-webhooks
|
||||
:main/delete-webhook}
|
||||
[[:webhook-read :bucket "20/10/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Share links
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/create-share-link
|
||||
:main/delete-share-link}
|
||||
[[:share-link :bucket "10/5/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Organization operations
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/add-team-to-organization
|
||||
:main/remove-team-from-org
|
||||
:main/all-org-members-in-team
|
||||
:main/all-team-members-in-orgs
|
||||
:main/get-owned-organizations-summary
|
||||
:main/get-leave-org-summary
|
||||
:main/leave-org
|
||||
:main/check-org-members
|
||||
:main/get-team-invitation-token
|
||||
:main/delete-team-invitation
|
||||
:main/check-team-external-invitations}
|
||||
[[:org-ops :bucket "20/10/1m"]]
|
||||
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Audit & stats
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/push-audit-events}
|
||||
[[:audit-events :bucket "1000/500/1m"]]
|
||||
|
||||
#{:main/logout
|
||||
:main/get-error-report
|
||||
:main/get-error-reports
|
||||
:main/get-current-mcp-token
|
||||
:main/get-nitrate-connectivity
|
||||
:main/check-nitrate-sso
|
||||
:main/redeem-nitrate-activation-code
|
||||
:main/create-demo-profile
|
||||
:main/get-subscription-warning}
|
||||
[[:misc-light :bucket "100/50/1m"]]}
|
||||
;; #{:main/get-profile}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
@@ -14,21 +14,10 @@
|
||||
:iterations 3
|
||||
:parallelism 2})
|
||||
|
||||
(def ^:private weak-options
|
||||
{:alg :pbkdf2+sha256
|
||||
:iterations 100})
|
||||
|
||||
(defn derive-password
|
||||
[password]
|
||||
(hashers/derive password default-options))
|
||||
|
||||
(defn derive-password-weak
|
||||
"Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations).
|
||||
Intended for demo users only — they are already gated behind the
|
||||
`demo-users` config flag which is disabled in production."
|
||||
[password]
|
||||
(hashers/derive password weak-options))
|
||||
|
||||
(defn verify-password
|
||||
[attempt password]
|
||||
(try
|
||||
|
||||
+78
-249
@@ -42,52 +42,31 @@
|
||||
;; OIDC PROVIDER (GENERIC)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- raise-invalid-sso-config
|
||||
"Raise a controlled validation error for OIDC provider configuration failures."
|
||||
[& {:keys [hint cause] :as params}]
|
||||
(throw (ex-info (or hint "invalid-sso-config")
|
||||
(-> params
|
||||
(dissoc :cause)
|
||||
(assoc :type :validation
|
||||
:code :invalid-sso-config))
|
||||
cause)))
|
||||
|
||||
(defn- discover-oidc-config
|
||||
[cfg {:keys [base-uri skip-ssrf-check?] :as provider}]
|
||||
(let [uri (u/join base-uri ".well-known/openid-configuration")]
|
||||
(try
|
||||
(let [rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
(if (= 200 (:status rsp))
|
||||
(let [data (-> rsp :body json/decode)
|
||||
token-uri (get data :token_endpoint)
|
||||
auth-uri (get data :authorization_endpoint)
|
||||
user-uri (get data :userinfo_endpoint)
|
||||
jwks-uri (get data :jwks_uri)
|
||||
logout-uri (get data :end_session_endpoint)]
|
||||
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
||||
rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
|
||||
(-> provider
|
||||
(assoc :token-uri token-uri)
|
||||
(assoc :auth-uri auth-uri)
|
||||
(assoc :user-uri user-uri)
|
||||
(assoc :jwks-uri jwks-uri)
|
||||
(assoc :logout-uri logout-uri)))
|
||||
(if (= 200 (:status rsp))
|
||||
(let [data (-> rsp :body json/decode)
|
||||
token-uri (get data :token_endpoint)
|
||||
auth-uri (get data :authorization_endpoint)
|
||||
user-uri (get data :userinfo_endpoint)
|
||||
jwks-uri (get data :jwks_uri)
|
||||
logout-uri (get data :end_session_endpoint)]
|
||||
|
||||
(raise-invalid-sso-config
|
||||
:hint "unable to discover OIDC configuration"
|
||||
:discover-uri uri
|
||||
:response-status-code (:status rsp))))
|
||||
(catch Throwable cause
|
||||
;; Controlled raises above are ExceptionInfo and would otherwise be
|
||||
;; re-wrapped by this catch, dropping fields like :response-status-code.
|
||||
(if (and (ex/error? cause)
|
||||
(= :invalid-sso-config (:code (ex-data cause))))
|
||||
(throw cause)
|
||||
;; Wrap SSRF blocks, DNS failures, TLS errors, etc. — from the caller's
|
||||
;; perspective these are all "bad/unreachable issuer URL".
|
||||
(raise-invalid-sso-config
|
||||
:hint "unable to discover OIDC configuration"
|
||||
:discover-uri uri
|
||||
:cause cause))))))
|
||||
(-> provider
|
||||
(assoc :token-uri token-uri)
|
||||
(assoc :auth-uri auth-uri)
|
||||
(assoc :user-uri user-uri)
|
||||
(assoc :jwks-uri jwks-uri)
|
||||
(assoc :logout-uri logout-uri)))
|
||||
|
||||
(ex/raise :type ::internal
|
||||
:code :invalid-sso-config
|
||||
:hint "unable to discover OIDC configuration"
|
||||
:discover-uri uri
|
||||
:response-status-code (:status rsp)))))
|
||||
|
||||
(def ^:private default-oidc-scopes
|
||||
#{"openid" "profile" "email"})
|
||||
@@ -128,29 +107,16 @@
|
||||
|
||||
(defn- fetch-oidc-jwks
|
||||
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
|
||||
(try
|
||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
(if (= 200 status)
|
||||
(-> body json/decode :keys process-oidc-jwks)
|
||||
(raise-invalid-sso-config
|
||||
:hint "unable to retrieve JWKs (unexpected response status code)"
|
||||
:jwks-uri jwks-uri
|
||||
:response-status-code status)))
|
||||
(catch Throwable cause
|
||||
(if (and (ex/error? cause)
|
||||
(= :invalid-sso-config (:code (ex-data cause))))
|
||||
(throw cause)
|
||||
(raise-invalid-sso-config
|
||||
:hint "unable to retrieve JWKs"
|
||||
:jwks-uri jwks-uri
|
||||
:cause cause)))))
|
||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
(if (= 200 status)
|
||||
(-> body json/decode :keys process-oidc-jwks)
|
||||
(ex/raise :type ::internal
|
||||
:code :unable-to-fetch-sso-jwks
|
||||
:hint "unable to retrieve JWKs (unexpected response status code)"
|
||||
:response-status-code status))))
|
||||
|
||||
(defn- populate-jwks
|
||||
"Fetch and add JWKs to the OIDC provider.
|
||||
|
||||
When `:strict-jwks?` is set (organization SSO), failures raise a controlled
|
||||
validation error. Otherwise JWKS is best-effort: log and continue without keys
|
||||
so global OIDC/GitLab providers can still initialize if JWKS is temporarily down."
|
||||
"Fetch and Add (if possible) JWK's to the OIDC provider"
|
||||
[cfg provider]
|
||||
(try
|
||||
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
|
||||
@@ -158,28 +124,20 @@
|
||||
(assoc provider :jwks jwks)
|
||||
provider)
|
||||
(catch Throwable cause
|
||||
(if (:strict-jwks? provider)
|
||||
(if (and (ex/error? cause)
|
||||
(= :invalid-sso-config (:code (ex-data cause))))
|
||||
(throw cause)
|
||||
(raise-invalid-sso-config
|
||||
:hint "unable to retrieve JWKs"
|
||||
:provider (:id provider)
|
||||
:cause cause))
|
||||
(do
|
||||
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
|
||||
:provider (str (:id provider))
|
||||
:cause cause)
|
||||
provider)))))
|
||||
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
|
||||
:provider (str (:id provider))
|
||||
:cause cause)
|
||||
provider)))
|
||||
|
||||
(defn- prepare-oidc-provider
|
||||
[cfg params]
|
||||
(when-not (and (string? (:base-uri params))
|
||||
(string? (:client-id params))
|
||||
(string? (:client-secret params)))
|
||||
(raise-invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
(ex/raise :type ::internal
|
||||
:code :invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
|
||||
(try
|
||||
(if (and (string? (:token-uri params))
|
||||
@@ -192,13 +150,11 @@
|
||||
(with-meta provider {::discovered true})))
|
||||
|
||||
(catch Throwable cause
|
||||
(if (and (ex/error? cause)
|
||||
(= :invalid-sso-config (:code (ex-data cause))))
|
||||
(throw cause)
|
||||
(raise-invalid-sso-config
|
||||
:hint "unexpected exception on configuring provider"
|
||||
:provider (:id params)
|
||||
:cause cause)))))
|
||||
(ex/raise :type ::internal
|
||||
:type :invalid-sso-config
|
||||
:hint "unexpected exception on configuring provider"
|
||||
:provider (:id params)
|
||||
:cause cause))))
|
||||
|
||||
(defmethod ig/assert-key ::providers/generic
|
||||
[_ params]
|
||||
@@ -366,9 +322,10 @@
|
||||
[cfg params]
|
||||
(when-not (and (string? (:client-id params))
|
||||
(string? (:client-secret params)))
|
||||
(raise-invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
(ex/raise :type ::internal
|
||||
:code :invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
|
||||
(try
|
||||
(let [provider (populate-jwks cfg params)]
|
||||
@@ -379,13 +336,11 @@
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
(catch Throwable cause
|
||||
(if (and (ex/error? cause)
|
||||
(= :invalid-sso-config (:code (ex-data cause))))
|
||||
(throw cause)
|
||||
(raise-invalid-sso-config
|
||||
:hint "unexpected exception on configuring provider"
|
||||
:provider (:id params)
|
||||
:cause cause)))))
|
||||
(ex/raise :type ::internal
|
||||
:type :invalid-sso-config
|
||||
:hint "unexpected exception on configuring provider"
|
||||
:provider (:id params)
|
||||
:cause cause))))
|
||||
|
||||
(defmethod ig/init-key ::providers/gitlab
|
||||
[_ cfg]
|
||||
@@ -665,6 +620,9 @@
|
||||
(some? (:external-session-id state))
|
||||
(assoc :external-session-id (:external-session-id state))
|
||||
|
||||
(some? (:token/expires-in tdata))
|
||||
(assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)}))
|
||||
|
||||
;; If state token comes with props, merge them. The state token
|
||||
;; props can contain pm_ and utm_ prefixed query params.
|
||||
(map? (:props state))
|
||||
@@ -692,15 +650,6 @@
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
(redirect-response uri))))
|
||||
|
||||
(defn- redirect-with-organization-sso-error
|
||||
[{:keys [dest-url organization-id organization-name]}]
|
||||
(-> (str (or dest-url (cf/get :public-uri)))
|
||||
(u/append-query-param :sso-error true)
|
||||
(u/append-query-param :organization-id organization-id)
|
||||
(cond-> organization-name
|
||||
(u/append-query-param :organization-name organization-name))
|
||||
(redirect-response)))
|
||||
|
||||
(defn- redirect-to-register
|
||||
[cfg info provider]
|
||||
(let [info (assoc info
|
||||
@@ -816,82 +765,6 @@
|
||||
;; ORG SSO HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- organization-sso-oauth-failure-reason
|
||||
[error]
|
||||
(case (d/name error)
|
||||
"access_denied" "access-denied"
|
||||
("temporarily_unavailable" "server_error") "provider-unavailable"
|
||||
("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration"
|
||||
"provider-error"))
|
||||
|
||||
(defn- organization-sso-exception-failure-reason
|
||||
[cause]
|
||||
(let [data (ex-data cause)
|
||||
status (or (:response-status data)
|
||||
(:response-status-code data)
|
||||
(:http-status data))
|
||||
network-error?
|
||||
(loop [current cause]
|
||||
(cond
|
||||
(nil? current)
|
||||
false
|
||||
|
||||
(or (instance? java.net.ConnectException current)
|
||||
(instance? java.net.UnknownHostException current)
|
||||
(instance? java.net.http.HttpTimeoutException current)
|
||||
(instance? javax.net.ssl.SSLException current))
|
||||
true
|
||||
|
||||
(identical? current (ex-cause current))
|
||||
false
|
||||
|
||||
:else
|
||||
(recur (ex-cause current))))]
|
||||
(if (or network-error?
|
||||
(and (number? status) (<= 500 status 599)))
|
||||
"provider-unavailable"
|
||||
(case (:code data)
|
||||
:unable-to-fetch-access-token "token-exchange-failed"
|
||||
:unable-to-retrieve-user-info "user-info-failed"
|
||||
:incomplete-user-info "incomplete-user-info"
|
||||
:invalid-sso-config "invalid-configuration"
|
||||
:unable-to-fetch-sso-jwks "provider-unavailable"
|
||||
:unable-to-auth "access-denied"
|
||||
"unexpected-error"))))
|
||||
|
||||
(defn- submit-organization-sso-auth-event
|
||||
[cfg request profile-id organization-id name & {:keys [failure-reason]}]
|
||||
(audit/submit cfg {:type "action"
|
||||
:name name
|
||||
:profile-id profile-id
|
||||
:ip-addr (inet/parse-request request)
|
||||
:props (d/without-nils
|
||||
{:organization-id organization-id
|
||||
:failure-reason failure-reason})
|
||||
:context (audit/prepare-context-from-request request)}))
|
||||
|
||||
(defn submit-organization-sso-auth-started-event
|
||||
[cfg request profile-id organization-id]
|
||||
(submit-organization-sso-auth-event
|
||||
cfg request profile-id organization-id "organization-sso-auth-started"))
|
||||
|
||||
(defn submit-organization-sso-auth-failed-event
|
||||
[cfg request profile-id organization-id cause]
|
||||
(submit-organization-sso-auth-event
|
||||
cfg request profile-id organization-id "organization-sso-auth-failed"
|
||||
:failure-reason (organization-sso-exception-failure-reason cause)))
|
||||
|
||||
(defn- submit-organization-sso-oauth-failed-event
|
||||
[cfg request state-token error]
|
||||
(try
|
||||
(let [state (tokens/verify cfg {:token state-token :iss "oidc"})]
|
||||
(when (:dest-url state)
|
||||
(submit-organization-sso-auth-event
|
||||
cfg request (some-> (session/get-session request) :profile-id)
|
||||
(:organization-id state) "organization-sso-auth-failed"
|
||||
:failure-reason (organization-sso-oauth-failure-reason error))))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn- non-blank-uri
|
||||
[value]
|
||||
(when-not (str/blank? value) value))
|
||||
@@ -912,10 +785,7 @@
|
||||
:base-uri (some-> (non-blank-uri issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes default-oidc-scopes
|
||||
;; Organization SSO is configured by customers; discovery
|
||||
;; and JWKS failures must surface as controlled errors.
|
||||
:strict-jwks? true}))
|
||||
:scopes default-oidc-scopes}))
|
||||
|
||||
(defn build-organization-sso-auth-redirect-uri
|
||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||
@@ -925,24 +795,16 @@
|
||||
issuer (organization-sso-discovery-uri sso)
|
||||
dest-url (or dest-url (str (cf/get :public-uri)))]
|
||||
(when-not issuer
|
||||
(raise-invalid-sso-config
|
||||
:hint "missing issuer"
|
||||
:organization-id organization-id))
|
||||
(try
|
||||
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
|
||||
state-token (tokens/generate cfg {:iss "oidc"
|
||||
:dest-url dest-url
|
||||
:organization-id organization-id
|
||||
:issuer issuer
|
||||
:exp (ct/in-future "4h")})]
|
||||
(build-auth-redirect-uri oidc-provider state-token))
|
||||
(catch Throwable cause
|
||||
(if (and (ex/error? cause)
|
||||
(= :invalid-sso-config (:code (ex-data cause))))
|
||||
(throw (ex-info (ex-message cause)
|
||||
(assoc (ex-data cause) :organization-id organization-id)
|
||||
(ex-cause cause)))
|
||||
(throw cause))))))
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-sso-config
|
||||
:hint "missing issuer"))
|
||||
(let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso))
|
||||
state-token (tokens/generate cfg {:iss "oidc"
|
||||
:dest-url dest-url
|
||||
:organization-id organization-id
|
||||
:issuer issuer
|
||||
:exp (ct/in-future "4h")})]
|
||||
(build-auth-redirect-uri oidc-provider state-token))))
|
||||
|
||||
(def ^:private probe-auth-code "penpot-sso-config-probe")
|
||||
|
||||
@@ -1025,53 +887,10 @@
|
||||
{::yres/status 200
|
||||
::yres/body {:redirect-uri uri}}))
|
||||
|
||||
(defn- organization-sso-callback-handler
|
||||
"Handle the organization-SSO branch of the OIDC callback: state carries
|
||||
:dest-url — exchange the authorization code with the OIDC provider to
|
||||
verify authentication actually occurred, then redirect back to dest-url."
|
||||
[cfg request state code]
|
||||
(let [dest-url (:dest-url state)]
|
||||
(try
|
||||
(let [organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
_info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:hours 4})]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
||||
(submit-organization-sso-auth-event
|
||||
cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded")
|
||||
(redirect-response dest-url))
|
||||
(catch Throwable cause
|
||||
(let [{:keys [code]} (ex-data cause)]
|
||||
(binding [l/*context* (errors/request->context request)]
|
||||
(if (some? code)
|
||||
(l/warn :hint "organization sso callback failed"
|
||||
:code code
|
||||
:message (ex-message cause)
|
||||
:organization-id (:organization-id state))
|
||||
(l/err :hint "unexpected error on organization sso callback"
|
||||
:organization-id (:organization-id state)
|
||||
:cause cause))))
|
||||
(submit-organization-sso-auth-failed-event
|
||||
cfg request (some-> (session/get-session request) :profile-id)
|
||||
(:organization-id state) cause)
|
||||
(let [organization-id (:organization-id state)
|
||||
organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))]
|
||||
(redirect-with-organization-sso-error
|
||||
{:dest-url dest-url
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name}))))))
|
||||
|
||||
(defn- callback-handler
|
||||
[cfg {:keys [params] :as request}]
|
||||
(if-let [error (get params :error)]
|
||||
(do
|
||||
(submit-organization-sso-oauth-failed-event cfg request (:state params) error)
|
||||
(redirect-with-error "unable-to-auth" error))
|
||||
(redirect-with-error "unable-to-auth" error)
|
||||
(try
|
||||
(let [code (get params :code)
|
||||
state (get params :state)
|
||||
@@ -1079,8 +898,18 @@
|
||||
|
||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(if (:dest-url state)
|
||||
(organization-sso-callback-handler cfg request state code)
|
||||
(if-let [dest-url (:dest-url state)]
|
||||
(let [organization-id (:organization-id state)
|
||||
sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id})
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
(session/update-session (::session/manager cfg) (assoc session :props props))))
|
||||
(redirect-response dest-url))
|
||||
|
||||
(let [provider (resolve-provider cfg state)
|
||||
info (get-info cfg provider state code)
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex])
|
||||
(:import
|
||||
[org.passay PasswordData]
|
||||
[org.passay.data EnglishCharacterData]
|
||||
[org.passay.rule CharacterCharacteristicsRule CharacterRule]))
|
||||
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
|
||||
|
||||
(defonce ^:private passay-code->translation-key
|
||||
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
|
||||
@@ -20,13 +18,12 @@
|
||||
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
|
||||
|
||||
(defonce ^:private character-characteristics-rule
|
||||
(CharacterCharacteristicsRule.
|
||||
4
|
||||
(into-array org.passay.rule.CharacterRule
|
||||
[(CharacterRule. EnglishCharacterData/LowerCase 1)
|
||||
(doto (CharacterCharacteristicsRule.)
|
||||
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
|
||||
(CharacterRule. EnglishCharacterData/UpperCase 1)
|
||||
(CharacterRule. EnglishCharacterData/Digit 1)
|
||||
(CharacterRule. EnglishCharacterData/Special 1)])))
|
||||
(CharacterRule. EnglishCharacterData/Special 1)])
|
||||
(.setNumberOfCharacteristics 4)))
|
||||
|
||||
(defn validate-password
|
||||
"Validates password strength.
|
||||
|
||||
@@ -723,7 +723,6 @@
|
||||
(-> (select-keys file file-attrs)
|
||||
(assoc :data nil)
|
||||
(dissoc :team-id)
|
||||
(dissoc :metadata)
|
||||
(dissoc :migrations)))
|
||||
|
||||
(defn- file->file-data-params
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.File
|
||||
java.io.FilterInputStream
|
||||
java.io.InputStream
|
||||
java.io.OutputStreamWriter
|
||||
java.lang.AutoCloseable
|
||||
@@ -393,7 +392,7 @@
|
||||
params {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:referer "penpot"
|
||||
:refer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}]
|
||||
(write-entry! output "manifest.json" params))))
|
||||
@@ -431,32 +430,6 @@
|
||||
[^ZipFile input ^ZipEntry entry]
|
||||
(.getInputStream input entry))
|
||||
|
||||
(defn- size-limiting-stream
|
||||
"Wraps an InputStream to enforce a maximum number of decompressed bytes.
|
||||
Raises :validation :max-file-size-reached when the limit is exceeded."
|
||||
^InputStream
|
||||
[^InputStream input ^long max-size]
|
||||
(let [counter (atom 0)
|
||||
on-read (fn [n]
|
||||
(when (pos? n)
|
||||
(when (> (swap! counter + (long n)) max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (str "stream exceeded max size: " max-size))))
|
||||
n)]
|
||||
(proxy [FilterInputStream] [input]
|
||||
(read
|
||||
([]
|
||||
(let [b (.read input)]
|
||||
(when (pos? b) (on-read 1))
|
||||
b))
|
||||
([^bytes buf]
|
||||
(on-read (.read input buf 0 (alength buf))))
|
||||
([^bytes buf off]
|
||||
(on-read (.read input buf (int off) (- (alength buf) (int off)))))
|
||||
([^bytes buf off len]
|
||||
(on-read (.read input buf (int off) (int len))))))))
|
||||
|
||||
(defn- zip-entry-reader
|
||||
[^ZipFile input ^ZipEntry entry]
|
||||
(-> (zip-entry-stream input entry)
|
||||
@@ -465,12 +438,10 @@
|
||||
(defn- zip-entry-storage-content
|
||||
"Wraps a ZipFile and ZipEntry into a penpot storage compatible
|
||||
object and avoid creating temporal objects"
|
||||
[input entry & {:keys [max-size]}]
|
||||
(let [stream-fn (fn []
|
||||
(cond-> (zip-entry-stream input entry)
|
||||
max-size (size-limiting-stream max-size)))
|
||||
hash (delay (->> (stream-fn)
|
||||
(sto.impl/calculate-hash)))]
|
||||
[input entry]
|
||||
(let [hash (delay (->> entry
|
||||
(zip-entry-stream input)
|
||||
(sto.impl/calculate-hash)))]
|
||||
(reify
|
||||
sto.impl/IContentObject
|
||||
(get-size [_]
|
||||
@@ -487,7 +458,7 @@
|
||||
(throw (UnsupportedOperationException. "not implemented")))
|
||||
|
||||
(make-input-stream [_ _]
|
||||
(stream-fn))
|
||||
(zip-entry-stream input entry))
|
||||
(make-output-stream [_ _]
|
||||
(throw (UnsupportedOperationException. "not implemented"))))))
|
||||
|
||||
@@ -763,7 +734,7 @@
|
||||
:plugin-data plugin-data}))
|
||||
|
||||
(defn- import-file
|
||||
[{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}]
|
||||
[{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}]
|
||||
(let [file-id' (bfc/lookup-index file-id)
|
||||
file (read-file cfg file-id)
|
||||
media (read-file-media cfg file-id)
|
||||
@@ -830,10 +801,8 @@
|
||||
(assoc :data data)
|
||||
(assoc :name file-name)
|
||||
(assoc :project-id project-id)
|
||||
(assoc :metadata (d/without-nils
|
||||
{:generated-by (get manifest :generated-by)
|
||||
:referer (or (get manifest :referer) (get manifest :refer))}))
|
||||
(dissoc :options))
|
||||
|
||||
file (bfc/process-file cfg file)
|
||||
file (ctf/check-file file)]
|
||||
|
||||
@@ -875,9 +844,9 @@
|
||||
|
||||
ext (cmedia/mtype->extension (:content-type object))
|
||||
path (str "objects/" id ext)
|
||||
content (zip-entry-storage-content input
|
||||
(get-zip-entry input path)
|
||||
:max-size (::bfc/import-max-object-size cfg))]
|
||||
content (->> path
|
||||
(get-zip-entry input)
|
||||
(zip-entry-storage-content input))]
|
||||
|
||||
(when (not= (:size object) (sto/get-size content))
|
||||
(ex/raise :type :validation
|
||||
@@ -887,15 +856,6 @@
|
||||
:expected-size (:size object)
|
||||
:found-size (sto/get-size content)))
|
||||
|
||||
(when-let [max (::bfc/import-max-object-size cfg)]
|
||||
(when (> (sto/get-size content) max)
|
||||
(ex/raise :type :validation
|
||||
:code :max-file-size-reached
|
||||
:hint (str "storage object exceeds maximum size: " (sto/get-size content))
|
||||
:path path
|
||||
:max max
|
||||
:found (sto/get-size content))))
|
||||
|
||||
(when-let [hash (get object :hash)]
|
||||
(when (not= hash (sto/get-hash content))
|
||||
(ex/raise :type :validation
|
||||
@@ -978,15 +938,6 @@
|
||||
(let [manifest (-> (read-manifest input)
|
||||
(validate-manifest))
|
||||
entries (read-zip-entries input)
|
||||
|
||||
_ (when-let [max (::bfc/import-max-zip-entries cfg)]
|
||||
(when (> (count entries) max)
|
||||
(ex/raise :type :validation
|
||||
:code :too-many-zip-entries
|
||||
:hint (str "zip file has too many entries: " (count entries))
|
||||
:max max
|
||||
:found (count entries))))
|
||||
|
||||
cfg (-> cfg
|
||||
(assoc ::entries entries)
|
||||
(assoc ::manifest manifest)
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
:redis-uri "redis://redis/0"
|
||||
|
||||
:file-data-backend "db"
|
||||
:file-data-backend "legacy-db"
|
||||
|
||||
:objects-storage-backend "fs"
|
||||
:objects-storage-fs-directory "assets"
|
||||
@@ -94,11 +94,7 @@
|
||||
|
||||
;; SSRF protection
|
||||
:ssrf-allowed-hosts #{}
|
||||
:ssrf-extra-blocked-cidrs #{}
|
||||
|
||||
;; Binfile import limits
|
||||
:binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
|
||||
:binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
|
||||
:ssrf-extra-blocked-cidrs #{}})
|
||||
|
||||
(def schema:config
|
||||
(do #_sm/optional-keys
|
||||
@@ -155,10 +151,6 @@
|
||||
[:media-processing-service-uri {:optional true} ::sm/uri]
|
||||
[:media-processing-service-timeout {:optional true} ::sm/int]
|
||||
|
||||
;; Binfile import limits (PENPOT_BINFILE_IMPORT_*)
|
||||
[:binfile-import-max-object-size {:optional true} ::sm/int]
|
||||
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
|
||||
|
||||
[:deletion-delay {:optional true} ::ct/duration]
|
||||
[:file-clean-delay {:optional true} ::ct/duration]
|
||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||
@@ -202,7 +194,6 @@
|
||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
||||
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
|
||||
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
|
||||
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
|
||||
|
||||
[:auth-token-cookie-name {:optional true} :string]
|
||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
com.zaxxer.hikari.HikariDataSource
|
||||
com.zaxxer.hikari.HikariPoolMXBean
|
||||
com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory
|
||||
io.whitfin.siphash.SipHash
|
||||
io.whitfin.siphash.SipHashContext
|
||||
io.whitfin.siphash.SipHasher
|
||||
io.whitfin.siphash.SipHasherContainer
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.sql.Connection
|
||||
@@ -701,12 +701,12 @@
|
||||
;; --- Locks
|
||||
|
||||
(def ^:private siphash-state
|
||||
(SipHash/context
|
||||
(uuid/get-bytes uuid/zero)))
|
||||
(SipHasher/container
|
||||
(uuid/get-bytes uuid/zero)))
|
||||
|
||||
(defn uuid->hash-code
|
||||
[o]
|
||||
(.hash ^SipHashContext siphash-state
|
||||
(.hash ^SipHasherContainer siphash-state
|
||||
^bytes (uuid/get-bytes o)))
|
||||
|
||||
(defn- xact-check-param
|
||||
|
||||
@@ -505,13 +505,13 @@
|
||||
:schema schema:request-file-access))
|
||||
|
||||
(def request-file-access-yourpenpot
|
||||
"File access on Personal Projects request email."
|
||||
"File access on Your Penpot request email."
|
||||
(template-factory
|
||||
:id ::request-file-access-yourpenpot
|
||||
:schema schema:request-file-access))
|
||||
|
||||
(def request-file-access-yourpenpot-view
|
||||
"File access on Personal Projects view mode request email."
|
||||
"File access on Your Penpot view mode request email."
|
||||
(template-factory
|
||||
:id ::request-file-access-yourpenpot-view
|
||||
:schema schema:request-file-access))
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.objects-map :as omap]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -160,17 +159,15 @@
|
||||
:content-type "application/octet-stream"
|
||||
:file-id file-id
|
||||
:id id})
|
||||
metadata (-> (:metadata params)
|
||||
(assoc :storage-ref-id (:id sobject)))
|
||||
metadata {:storage-ref-id (:id sobject)}
|
||||
params (-> params
|
||||
(assoc :metadata metadata)
|
||||
(assoc :data nil))]
|
||||
(upsert-in-database cfg params))
|
||||
|
||||
(= backend "db")
|
||||
(let [metadata (dissoc (:metadata params) :storage-ref-id)
|
||||
params (assoc params :metadata metadata)]
|
||||
(upsert-in-database cfg params))
|
||||
(->> (dissoc params :metadata)
|
||||
(upsert-in-database cfg))
|
||||
|
||||
(= backend "legacy-db")
|
||||
(cond
|
||||
@@ -216,11 +213,18 @@
|
||||
[backend]
|
||||
(or backend (cf/get :file-data-backend)))
|
||||
|
||||
(def ^:private schema:metadata
|
||||
[:map {:title "Metadata"}
|
||||
[:storage-ref-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(def decode-metadata-with-schema
|
||||
(sm/decoder schema:metadata sm/json-transformer))
|
||||
|
||||
(defn decode-metadata
|
||||
[metadata]
|
||||
(some-> metadata
|
||||
(db/decode-json-pgobject)
|
||||
(ctf/decode-file-metadata)))
|
||||
(decode-metadata-with-schema)))
|
||||
|
||||
(def ^:private schema:update-params
|
||||
[:map {:closed true}
|
||||
@@ -228,7 +232,7 @@
|
||||
[:type [:enum "main" "snapshot" "fragment"]]
|
||||
[:file-id ::sm/uuid]
|
||||
[:backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
||||
[:metadata {:optional true} ctf/schema:file-metadata]
|
||||
[:metadata {:optional true} [:maybe schema:metadata]]
|
||||
[:data {:optional true} bytes?]
|
||||
[:created-at {:optional true} ::ct/inst]
|
||||
[:modified-at {:optional true} [:maybe ::ct/inst]]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.http.assets
|
||||
"Assets related handlers."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
@@ -14,7 +15,6 @@
|
||||
[app.db :as db]
|
||||
[app.http.access-token :as actoken]
|
||||
[app.http.session :as session]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.storage :as sto]
|
||||
[integrant.core :as ig]
|
||||
[yetti.response :as-alias yres]))
|
||||
@@ -41,12 +41,6 @@
|
||||
(ex/raise :type :not-found
|
||||
:hint "object not found")))
|
||||
|
||||
(defn- get-share-id
|
||||
"Extract and validate the optional `share-id` query param. Returns a UUID
|
||||
or `nil` for missing/malformed values."
|
||||
[{:keys [query-params]}]
|
||||
(some-> query-params :share-id d/parse-uuid))
|
||||
|
||||
(defn- get-file-media-object
|
||||
[pool id]
|
||||
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
@@ -55,16 +49,12 @@
|
||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||
cch-max-age (or cache-max-age default-cache-max-age)
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})
|
||||
bucket (-> obj meta :bucket)
|
||||
headers (cond-> {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not (contains? public-buckets bucket))
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
||||
{::yres/status 307
|
||||
::yres/headers headers}))
|
||||
::yres/headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path ::cache-max-age]} obj]
|
||||
@@ -72,12 +62,9 @@
|
||||
purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
bucket (:bucket mdata)
|
||||
headers (cond-> {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
|
||||
(not (contains? public-buckets bucket))
|
||||
(assoc "content-disposition" "attachment"))]
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
|
||||
@@ -95,32 +82,17 @@
|
||||
(let [bucket (-> obj meta :bucket)]
|
||||
(not (contains? public-buckets bucket))))
|
||||
|
||||
(defn- request-profile-id
|
||||
"Extract the authenticated profile-id from the request."
|
||||
[request]
|
||||
(or (::session/profile-id request)
|
||||
(::actoken/profile-id request)))
|
||||
|
||||
(defn- authenticated?
|
||||
"Check if the request has an authenticated profile, either via session
|
||||
or access token."
|
||||
[request]
|
||||
(some? (request-profile-id request)))
|
||||
|
||||
(defn- tempfile-owner-match?
|
||||
"Check if the request's profile-id matches the tempfile's stored owner.
|
||||
Returns true if no profile-id was stored (legacy objects)."
|
||||
[obj request]
|
||||
(let [stored-profile-id (:profile-id (meta obj))
|
||||
request-profile-id (request-profile-id request)]
|
||||
(or (nil? stored-profile-id)
|
||||
(= stored-profile-id request-profile-id))))
|
||||
(or (some? (::session/profile-id request))
|
||||
(some? (::actoken/profile-id request))))
|
||||
|
||||
(defn objects-handler
|
||||
"Handler that serves storage objects by id.
|
||||
For non-public buckets (e.g. profile), requires authentication
|
||||
via session cookie or access token.
|
||||
For tempfile bucket, also requires ownership (profile-id match)."
|
||||
via session cookie or access token."
|
||||
[{:keys [::sto/storage] :as cfg} request]
|
||||
(let [id (get-id request)
|
||||
obj (sto/get-object storage id)]
|
||||
@@ -132,10 +104,6 @@
|
||||
(not (authenticated? request)))
|
||||
{::yres/status 401}
|
||||
|
||||
(and (= (-> obj meta :bucket) sto/tempfile-bucket)
|
||||
(not (tempfile-owner-match? obj request)))
|
||||
{::yres/status 404}
|
||||
|
||||
:else
|
||||
(serve-object cfg obj))))
|
||||
|
||||
@@ -150,8 +118,7 @@
|
||||
(let [file-id (:file-id mobj)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
share-id (get-share-id request)
|
||||
perms (perms/get-file-read-permissions pool profile-id file-id share-id)]
|
||||
perms (bfc/get-file-permissions pool profile-id file-id)]
|
||||
(if-not (:can-read perms)
|
||||
{::yres/status 404}
|
||||
(let [sobj (sto/get-object storage (kf mobj))]
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.changes :as cfc]
|
||||
[app.common.files.repair :as cfr]
|
||||
[app.common.files.validate :as cfv]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.time :as ct]
|
||||
@@ -31,7 +28,6 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.setup :as-alias setup]
|
||||
[app.setup.clock :as clock]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.srepl.main :as srepl]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
@@ -134,7 +130,7 @@
|
||||
:hint "invalid button"))
|
||||
|
||||
(ex/raise :type :not-found
|
||||
:code :empty-data
|
||||
:code :enpty-data
|
||||
:hint "empty response"))))
|
||||
|
||||
(defn- is-file-exists?
|
||||
@@ -322,9 +318,7 @@
|
||||
::bfc/overwrite false
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path
|
||||
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
::bfc/input path)]
|
||||
(bf.v3/import-files! cfg)
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
@@ -360,9 +354,7 @@
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path
|
||||
::bfc/features (cfeat/get-team-enabled-features cf/flags team)
|
||||
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
::bfc/features (cfeat/get-team-enabled-features cf/flags team))]
|
||||
|
||||
(if (= format :binfile-v3)
|
||||
(bf.v3/import-files! cfg)
|
||||
@@ -492,89 +484,6 @@
|
||||
{::yres/status 302
|
||||
::yres/headers {"location" "/dbg"}}))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; VALIDATE / REPAIR
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- validate-file
|
||||
[cfg {:keys [params] :as request}]
|
||||
(let [file-id (some-> params :file-id parse-uuid)]
|
||||
|
||||
(when-not file-id
|
||||
(ex/raise :type :validation
|
||||
:code :missing-arguments))
|
||||
|
||||
(db/tx-run! (assoc cfg ::db/rollback true)
|
||||
(fn [cfg]
|
||||
(let [file (bfc/get-file cfg file-id)
|
||||
libs (bfc/get-resolved-file-libraries cfg file-id)]
|
||||
(if file
|
||||
(let [errors (cfv/validate-file file libs)]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body (if (empty? errors)
|
||||
"NO VALIDATION ERRORS FOUND"
|
||||
(pp/pprint-str errors))})
|
||||
(ex/raise :type :not-found
|
||||
:code :empty-data
|
||||
:hint "empty response")))))))
|
||||
|
||||
(defn- repair-file
|
||||
[cfg {:keys [params] :as request}]
|
||||
(let [file-id (some-> params :file-id parse-uuid)
|
||||
skip-snapshot? (contains? params :skip-snapshot)
|
||||
profile-id (:app.http.session/profile-id request)]
|
||||
|
||||
(when-not file-id
|
||||
(ex/raise :type :validation
|
||||
:code :missing-arguments))
|
||||
|
||||
(let [output (StringBuilder.)
|
||||
|
||||
repair-file
|
||||
(fn [file libs _]
|
||||
(let [errors (cfv/validate-file file libs)]
|
||||
(.append output (if (empty? errors)
|
||||
"NO VALIDATION ERRORS FOUND\n"
|
||||
(str "VALIDATION ERRORS FOUND:\n"
|
||||
(pp/pprint-str errors) "\n")))
|
||||
(if (empty? errors)
|
||||
file
|
||||
(let [changes (cfr/repair-file file libs errors)]
|
||||
(-> file
|
||||
(update :revn inc)
|
||||
(update :data cfc/process-changes changes))))))]
|
||||
|
||||
(add-watch l/log-record ::repair-watcher
|
||||
(fn [_ _ _ record]
|
||||
(when (= "app.common.files.repair" (::l/logger record))
|
||||
(let [props (::l/props record)
|
||||
hint (get props :hint "")
|
||||
args (dissoc props :hint)
|
||||
message (str hint " "
|
||||
(when-not (empty? args)
|
||||
args)
|
||||
"\n")]
|
||||
(.append output message)))))
|
||||
(try
|
||||
(db/tx-run! cfg
|
||||
h/process-file!
|
||||
file-id
|
||||
repair-file
|
||||
{::h/with-libraries? true
|
||||
::h/validate? false
|
||||
::h/profile-id profile-id
|
||||
::h/snapshot-label (when-not skip-snapshot? "repair")})
|
||||
|
||||
(.append output "\nREPAIR FINISHED")
|
||||
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body (.toString output)}
|
||||
|
||||
(finally
|
||||
(remove-watch l/log-record ::repair-watcher))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; OTHER SMALL VIEWS/HANDLERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -669,7 +578,5 @@
|
||||
{:handler (partial handle-team-features cfg)}]
|
||||
["/file-export" {:handler (partial export-handler cfg)}]
|
||||
["/file-import" {:handler (partial import-handler cfg)}]
|
||||
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]
|
||||
["/file-validate" {:handler (partial validate-file cfg)}]
|
||||
["/file-repair" {:handler (partial repair-file cfg)}]]]])
|
||||
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
|
||||
|
||||
@@ -34,12 +34,6 @@
|
||||
(assoc :request/auth-data (dissoc auth :token))
|
||||
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||
|
||||
(defn- strip-internal-fields
|
||||
"Remove fields that leak internal implementation details from error
|
||||
response data. Full context is preserved in server-side logs."
|
||||
[data]
|
||||
(dissoc data :state :path :context))
|
||||
|
||||
(defmulti handle-error
|
||||
(fn [cause _ _]
|
||||
(-> cause ex-data :type)))
|
||||
@@ -142,7 +136,6 @@
|
||||
(l/error :hint "assertion error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> data
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(assoc :code :assertion))})))))
|
||||
|
||||
@@ -168,9 +161,9 @@
|
||||
(l/error :hint "internal error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> data
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(update :code #(or % :unhandled)))})))
|
||||
(update :code #(or % :unhandled))
|
||||
(assoc :hint (ex-message error)))})))
|
||||
|
||||
(defmethod handle-error :default
|
||||
[error request parent-cause]
|
||||
@@ -185,20 +178,6 @@
|
||||
(handle-exception (:handling edata) request error)
|
||||
(handle-exception error request parent-cause))))
|
||||
|
||||
(defn- pgsql-state->message
|
||||
"Map PostgreSQL SQLSTATE codes to safe, client-facing messages.
|
||||
Returns a user-friendly string that conveys the nature of the error
|
||||
without exposing table names, constraint names, or other internals."
|
||||
[state]
|
||||
(case state
|
||||
"23505" "A conflicting entry already exists"
|
||||
"23503" "The referenced item does not exist"
|
||||
"23502" "A required field is missing"
|
||||
"23514" "The value violates a data integrity constraint"
|
||||
"57014" "The operation took too long and was cancelled"
|
||||
"25P03" "The transaction was idle too long and was cancelled"
|
||||
"A database error occurred"))
|
||||
|
||||
(defmethod handle-exception org.postgresql.util.PSQLException
|
||||
[error request parent-cause]
|
||||
(let [state (.getSQLState ^java.sql.SQLException error)
|
||||
@@ -211,19 +190,20 @@
|
||||
{::yres/status 504
|
||||
::yres/body {:type :server-error
|
||||
:code :statement-timeout
|
||||
:hint (pgsql-state->message state)}}
|
||||
:hint (ex-message error)}}
|
||||
|
||||
(= state "25P03")
|
||||
{::yres/status 504
|
||||
::yres/body {:type :server-error
|
||||
:code :idle-in-transaction-timeout
|
||||
:hint (pgsql-state->message state)}}
|
||||
:hint (ex-message error)}}
|
||||
|
||||
:else
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :database-error
|
||||
:hint (pgsql-state->message state)}}))))
|
||||
:code :unexpected
|
||||
:hint (ex-message error)
|
||||
:state state}}))))
|
||||
|
||||
(defmethod handle-exception :default
|
||||
[error request parent-cause]
|
||||
@@ -236,16 +216,17 @@
|
||||
(l/error :hint "unexpected error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :unexpected}})
|
||||
:code :unexpected
|
||||
:hint (ex-message error)}})
|
||||
|
||||
:else
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/error :hint "unhandled error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> edata
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(update :code #(or % :unhandled)))}))))
|
||||
(update :code #(or % :unhandled))
|
||||
(assoc :hint (ex-message error)))}))))
|
||||
|
||||
(defmethod handle-exception java.io.IOException
|
||||
[cause request _]
|
||||
@@ -253,7 +234,9 @@
|
||||
(l/wrn :hint "io exception" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :io-exception}}))
|
||||
:code :io-exception
|
||||
:hint (ex-message cause)
|
||||
:path (:path request)}}))
|
||||
|
||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||
[cause request _]
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
[:enum
|
||||
"customer_service"
|
||||
"low_quality"
|
||||
"missing_features"
|
||||
"missing_feature"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
(:import
|
||||
io.undertow.server.RequestTooBigException
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.security.MessageDigest))
|
||||
java.io.OutputStream))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -83,18 +82,18 @@
|
||||
(instance? IllegalArgumentException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint "invalid JSON in request body"
|
||||
:hint (ex-message cause)
|
||||
:cause cause)
|
||||
|
||||
(instance? RequestTooBigException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :request-body-too-large
|
||||
:hint "request body exceeds size limit")
|
||||
:hint (ex-message cause))
|
||||
|
||||
(instance? java.io.EOFException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint "unexpected end of request body"
|
||||
:hint (ex-message cause)
|
||||
:cause cause)
|
||||
|
||||
(instance? RuntimeException cause)
|
||||
@@ -330,11 +329,6 @@
|
||||
{:name ::auth
|
||||
:compile (constantly wrap-auth)})
|
||||
|
||||
(defn- constant-time-eq?
|
||||
"Compare strings in constant time to prevent timing attacks."
|
||||
[^String a ^String b]
|
||||
(MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8")))
|
||||
|
||||
(defn- wrap-shared-key-auth
|
||||
[handler keys]
|
||||
(if (seq keys)
|
||||
@@ -344,7 +338,7 @@
|
||||
(let [key-id (-> key-id str/lower keyword)]
|
||||
(if (and (string? key)
|
||||
(contains? keys key-id)
|
||||
(constant-time-eq? key (get keys key-id)))
|
||||
(= key (get keys key-id)))
|
||||
(-> request
|
||||
(assoc ::http/auth-key-id key-id)
|
||||
(handler))
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
[{:keys [::manager]}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(fn [request response]
|
||||
(some->> (get request ::session) :id (delete-session manager))
|
||||
(some->> (get request ::id) (delete-session manager))
|
||||
(clear-session-cookie response)))
|
||||
|
||||
(defn decode-token
|
||||
@@ -226,14 +226,6 @@
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(db/get-update-count))))
|
||||
|
||||
(defn invalidate-all
|
||||
"Delete all sessions for a given profile. Used when a profile is deleted
|
||||
to ensure immediate access revocation across all devices."
|
||||
[cfg profile-id]
|
||||
(let [sql "delete from http_session_v2 where profile_id = ?"]
|
||||
(-> (db/exec-one! cfg [sql profile-id])
|
||||
(db/get-update-count))))
|
||||
|
||||
(def ^:private sql:clear-organization-sso-sessions
|
||||
(str "UPDATE http_session_v2 "
|
||||
"SET props = props #- ARRAY['~:sso', ?]::text[] "
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(ns app.http.websocket
|
||||
"A penpot notification service for file cooperative edition."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@@ -18,8 +17,6 @@
|
||||
[app.http.session :as session]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.util.websocket :as ws]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec.csp :as sp]
|
||||
@@ -134,9 +131,8 @@
|
||||
(mbus/pub! msgbus :topic topic :message msg))))
|
||||
|
||||
(defmethod handle-message :subscribe-team
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
|
||||
(teams/check-read-permissions! pool profile-id team-id)
|
||||
(let [prev-subs (get @state ::team-subscription)
|
||||
channel (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
@@ -154,10 +150,8 @@
|
||||
|
||||
|
||||
(defmethod handle-message :subscribe-file
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
|
||||
(bfc/check-file-exists pool file-id)
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(let [psub (::file-subscription @state)
|
||||
fch (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
|
||||
@@ -36,16 +36,6 @@
|
||||
(def ^:private filter-auth-events
|
||||
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
|
||||
|
||||
(def ^:private organization-sso-failure-reasons
|
||||
#{"access-denied"
|
||||
"provider-unavailable"
|
||||
"invalid-configuration"
|
||||
"provider-error"
|
||||
"token-exchange-failed"
|
||||
"user-info-failed"
|
||||
"incomplete-user-info"
|
||||
"unexpected-error"})
|
||||
|
||||
(def ^:private safe-backend-context-keys
|
||||
#{:version
|
||||
:initiator
|
||||
@@ -307,14 +297,6 @@
|
||||
(defn filter-telemetry-props
|
||||
[{:keys [source name props type] :as params}]
|
||||
(cond
|
||||
(and (= source "backend")
|
||||
(= name "organization-sso-auth-failed"))
|
||||
(let [props' (into {} xf:filter-telemetry-props props)
|
||||
props' (cond-> props'
|
||||
(contains? organization-sso-failure-reasons (:failure-reason props))
|
||||
(assoc :failure-reason (:failure-reason props)))]
|
||||
(assoc params :props props'))
|
||||
|
||||
(or (and (= source "frontend")
|
||||
(= type "identify"))
|
||||
(and (= source "backend")
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(ns app.loggers.mattermost
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
@@ -26,7 +25,7 @@
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [id] :as report}]
|
||||
(let [type (get report :type)
|
||||
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
|
||||
text (str "#" type " | " (get report :hint) "\n"
|
||||
(when id
|
||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||
|
||||
@@ -39,7 +38,7 @@
|
||||
"- tenant: #" (:tenant report) "\n"
|
||||
"- origin: #" (:origin report) "\n"
|
||||
(when-let [href (get report :href)]
|
||||
(str "- href: `" (d/escape-markdown href) "`\n"))
|
||||
(str "- href: `" href "`\n"))
|
||||
(when-let [version (get report :frontend-version)]
|
||||
(str "- frontend-version: `" version "`\n"))
|
||||
(when-let [version (get report :backend-version)]
|
||||
|
||||
@@ -392,8 +392,6 @@
|
||||
|
||||
:delete-object
|
||||
(ig/ref :app.tasks.delete-object/handler)
|
||||
:demo-purge
|
||||
(ig/ref :app.tasks.demo-purge/handler)
|
||||
:process-webhook-event
|
||||
(ig/ref ::webhooks/process-event-handler)
|
||||
:run-webhook
|
||||
@@ -431,9 +429,6 @@
|
||||
:app.tasks.delete-object/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.demo-purge/handler
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.file-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
+11
-57
@@ -167,9 +167,6 @@
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||
[:sso-active {:optional true} [:maybe ::sm/boolean]]
|
||||
[:teams
|
||||
[:vector
|
||||
[:map
|
||||
@@ -245,7 +242,7 @@
|
||||
[:enum
|
||||
"customer_service"
|
||||
"low_quality"
|
||||
"missing_features"
|
||||
"missing_feature"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
@@ -262,14 +259,6 @@
|
||||
(generate-nitrate-uri "api/teams/" team-id)
|
||||
cto/schema:team-with-organization params))
|
||||
|
||||
(defn- get-teams-organizations-api
|
||||
[cfg {:keys [team-ids] :as params}]
|
||||
(let [params (assoc params :request-params {:team-ids team-ids})]
|
||||
(request-to-nitrate cfg :post
|
||||
(generate-nitrate-uri "api/teams/organizations")
|
||||
[:vector cto/schema:team-with-organization]
|
||||
params)))
|
||||
|
||||
(defn- get-organization-membership-api
|
||||
[cfg {:keys [profile-id organization-id] :as params}]
|
||||
(request-to-nitrate cfg :get
|
||||
@@ -500,7 +489,6 @@
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :admin-console)
|
||||
{:get-team-organization (partial get-team-organization-api cfg)
|
||||
:get-teams-organizations (partial get-teams-organizations-api cfg)
|
||||
:set-team-organization (partial set-team-organization-api cfg)
|
||||
:get-organization-membership (partial get-organization-membership-api cfg)
|
||||
:get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg)
|
||||
@@ -608,25 +596,22 @@
|
||||
:cause cause)
|
||||
profile)))))
|
||||
|
||||
(defn- apply-organization-info-to-team
|
||||
[team team-with-organization]
|
||||
(let [organization (:organization team-with-organization)]
|
||||
(if (some? organization)
|
||||
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||
(when-let [logo-id (:logo-id organization)]
|
||||
(generate-public-uri "assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||
team)))
|
||||
|
||||
(defn add-organization-info-to-team
|
||||
"Enriches a team map with organization information from Nitrate.
|
||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||
Returns the original team unchanged if the request fails or organization data is nil.
|
||||
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||
[cfg team params]
|
||||
(try
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
team-with-organization (call cfg :get-team-organization params)]
|
||||
(apply-organization-info-to-team team team-with-organization))
|
||||
(let [params (assoc (or params {}) :team-id (:id team))
|
||||
team-with-organization (call cfg :get-team-organization params)
|
||||
organization (:organization team-with-organization)]
|
||||
(if (some? organization)
|
||||
(-> (cto/apply-organization team (assoc organization :custom-photo
|
||||
(when-let [logo-id (:logo-id organization)]
|
||||
(generate-public-uri "assets/by-id/" logo-id))))
|
||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization)))))
|
||||
team))
|
||||
(catch Throwable cause
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
(throw cause)
|
||||
@@ -636,23 +621,6 @@
|
||||
:cause cause)
|
||||
team)))))
|
||||
|
||||
(defn add-organization-info-to-teams
|
||||
"Enriches teams with organization information using one batched Nitrate request.
|
||||
Teams absent from the Nitrate response are returned unchanged.
|
||||
Rejects the request when Nitrate does not return a valid batch response."
|
||||
[cfg teams params]
|
||||
(let [request-params (assoc (or params {}) :team-ids (mapv :id teams))
|
||||
teams-with-organization (call cfg :get-teams-organizations request-params)]
|
||||
(when (nil? teams-with-organization)
|
||||
(ex/raise :type :nitrate-unavailable
|
||||
:hint "nitrate did not return a valid teams organization response"))
|
||||
(let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)]
|
||||
(mapv (fn [{:keys [id] :as team}]
|
||||
(if-let [team-with-organization (get organizations-by-team id)]
|
||||
(apply-organization-info-to-team team team-with-organization)
|
||||
team))
|
||||
teams))))
|
||||
|
||||
(defn set-team-organization
|
||||
"Associates a team with an organization in Nitrate.
|
||||
Requires organization-id and is-default in params.
|
||||
@@ -669,17 +637,3 @@
|
||||
:context {:team-id (:id team)
|
||||
:organization-id (:organization-id params)}))
|
||||
team))
|
||||
|
||||
(defn assert-membership
|
||||
"Verifies that the user is a member of the organization.
|
||||
Raises an exception if the organization doesn't exist or the user is not a member."
|
||||
[cfg profile-id organization-id]
|
||||
(let [membership (call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(when-not (:organization-id membership)
|
||||
(ex/raise :type :validation
|
||||
:code :organization-does-not-exist))
|
||||
|
||||
(when-not (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
:code :user-doesnt-belong-organization))))
|
||||
+14
-26
@@ -261,28 +261,23 @@
|
||||
(defn- wrap-nitrate-sso
|
||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||
|
||||
Resolves the organization/team context from request params:
|
||||
1. Explicit :organization-id param identifies the organization directly
|
||||
2. The team comes from the first available of: explicit :team-id, explicit
|
||||
:project-id -> lookup project.team_id, explicit :file-id -> lookup file's
|
||||
team via join, or the :id param dispatched by ::rpc/id-type metadata
|
||||
(:team, :project, or :file)
|
||||
Resolves the organization/team context from request params using priority order:
|
||||
1. Explicit :organization-id param
|
||||
2. Explicit :team-id param
|
||||
3. Explicit :project-id param -> lookup project.team_id
|
||||
4. Explicit :file-id param -> lookup file's team via join
|
||||
5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
|
||||
|
||||
Once the context is resolved, checks if the user is authorized within that organization's
|
||||
SSO session using nitrate/sso-session-authorized?, against the organization when it is
|
||||
known and against the team otherwise. The team is resolved either way, so the raised
|
||||
error can carry it. Authorized results are cached by [profile-id cache-ref] for 15
|
||||
minutes to avoid repeated lookups.
|
||||
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
||||
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
||||
|
||||
Only activates when:
|
||||
- Nitrate flag is enabled
|
||||
- Endpoint requires authentication (::auth true by default)
|
||||
- Endpoint is not marked with ::nitrate/organization-sso false
|
||||
|
||||
Raises :nitrate-sso-required error if user is not authorized in the organization.
|
||||
The error carries the resolved :organization-id and :team-id so the client can
|
||||
restart the SSO flow (via :check-nitrate-sso) instead of reporting a plain
|
||||
permission failure."
|
||||
Raises :nitrate-sso-required error if user is not authorized in the organization."
|
||||
[_ f mdata]
|
||||
(if (and (contains? cf/flags :admin-console)
|
||||
(::auth mdata true) ;; only for endpoints that needs auth
|
||||
@@ -307,22 +302,17 @@
|
||||
cached (cache/get organization-sso-auth-cache cache-key)
|
||||
result (if (some? cached)
|
||||
cached
|
||||
;; The team is resolved even when the organization is
|
||||
;; already known: the client needs it to restart the
|
||||
;; SSO flow without sending non-members through the
|
||||
;; organization's identity provider.
|
||||
(let [team-id (or team-id
|
||||
(when project-id
|
||||
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
||||
(when file-id
|
||||
(let [team-id (when-not organization-id
|
||||
(or team-id
|
||||
(when project-id
|
||||
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
|
||||
(:id (teams/get-team-for-file cfg file-id))))
|
||||
request (-> (meta params) (get ::http/request))
|
||||
{:keys [authorized sso]} (if organization-id
|
||||
(nitrate/sso-session-authorized? cfg organization-id nil request)
|
||||
(nitrate/sso-session-authorized? cfg nil team-id request))
|
||||
entry {:authorized authorized
|
||||
:organization-id (or (:organization-id sso) organization-id)
|
||||
:team-id team-id}]
|
||||
:organization-id (:organization-id sso)}]
|
||||
(when authorized
|
||||
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
||||
entry))]
|
||||
@@ -330,8 +320,6 @@
|
||||
(f cfg params)
|
||||
(ex/raise :type :authentication
|
||||
:code :nitrate-sso-required
|
||||
:organization-id (:organization-id result)
|
||||
:team-id (:team-id result)
|
||||
:hint "organization SSO authentication required")))
|
||||
(f cfg params))))
|
||||
f))
|
||||
|
||||
@@ -183,7 +183,6 @@
|
||||
|
||||
(sv/defmethod ::get-enabled-flags
|
||||
{::audit/skip true
|
||||
::rpc/auth false
|
||||
::doc/skip true
|
||||
::doc/added "1.20"}
|
||||
[_cfg _params]
|
||||
|
||||
@@ -367,7 +367,7 @@
|
||||
email (str/lower email)
|
||||
fullname (d/normalize-string (:fullname params))
|
||||
locale (d/normalize-string locale)
|
||||
theme (some-> theme d/normalize-string not-empty)
|
||||
theme (d/normalize-string theme)
|
||||
|
||||
photo-id (some->> (or (:oidc/picture props)
|
||||
(:google/picture props)
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media.validation :as media.v]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.media :as media-cmd]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
@@ -60,7 +59,7 @@
|
||||
{::sto/content data
|
||||
::sto/touched-at (ct/in-future {:minutes 60})
|
||||
:content-type "application/zip"
|
||||
:bucket sto/tempfile-bucket})]
|
||||
:bucket "tempfile"})]
|
||||
|
||||
(-> (cf/get :public-uri)
|
||||
(u/join "/assets/by-id/")
|
||||
@@ -93,9 +92,7 @@
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/name name)
|
||||
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
|
||||
(assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))
|
||||
(assoc ::bfc/name name))
|
||||
|
||||
input-path (:path file)
|
||||
owned? (some? upload-id)
|
||||
@@ -107,11 +104,7 @@
|
||||
(try
|
||||
(case (int version)
|
||||
1 (bf.v1/import-files! cfg)
|
||||
3 (bf.v3/import-files! cfg)
|
||||
(throw (ex-info (str "Unsupported binfile version: " version)
|
||||
{:type :validation
|
||||
:code :unsupported-version
|
||||
:version version})))
|
||||
3 (bf.v3/import-files! cfg))
|
||||
(finally
|
||||
(when owned?
|
||||
(fs/delete input-path))))]
|
||||
@@ -125,11 +118,11 @@
|
||||
|
||||
(def ^:private schema:import-binfile
|
||||
[:and
|
||||
[:map {:title "import-binfile"}
|
||||
[:map {:title "import-binfile" :closed true}
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:project-id ::sm/uuid]
|
||||
[:version {:optional true} [:enum 1 3]]
|
||||
[:version {:optional true} ::sm/int]
|
||||
[:file {:optional true} media.v/schema:upload]
|
||||
[:upload-id {:optional true} ::sm/uuid]]
|
||||
[:fn {:error/message "one of :file or :upload-id is required"}
|
||||
@@ -149,33 +142,24 @@
|
||||
|
||||
::webhooks/event? true
|
||||
::sse/stream? true
|
||||
::sm/params schema:import-binfile
|
||||
::climit/id [[:import-binfile/by-profile ::rpc/profile-id]
|
||||
[:import-binfile/global]]}
|
||||
::sm/params schema:import-binfile}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
|
||||
(projects/check-edition-permissions! pool profile-id project-id)
|
||||
(let [params (if (some? upload-id)
|
||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)]
|
||||
(assoc params :file file))
|
||||
params)
|
||||
|
||||
version (or version
|
||||
(case (bfc/parse-file-format (-> params :file :path))
|
||||
:binfile-v1 1
|
||||
:binfile-v3 3))
|
||||
|
||||
(let [version (or version 3)
|
||||
params (-> params
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :version version))
|
||||
|
||||
params
|
||||
(if (some? upload-id)
|
||||
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
|
||||
(assoc params :file file))
|
||||
params)
|
||||
|
||||
manifest
|
||||
(case (int version)
|
||||
1 nil
|
||||
3 (bf.v3/get-manifest (-> params :file :path))
|
||||
(throw (ex-info (str "Unsupported binfile version: " version)
|
||||
{:type :validation
|
||||
:code :unsupported-version
|
||||
:version version})))]
|
||||
3 (bf.v3/get-manifest (-> params :file :path)))]
|
||||
|
||||
(with-meta
|
||||
(sse/response (partial import-binfile cfg params))
|
||||
|
||||
@@ -231,11 +231,8 @@
|
||||
::sm/params schema:get-comment-threads}
|
||||
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
threads (get-comment-threads conn profile-id file-id)]
|
||||
(if (= :share-link (:type perms))
|
||||
(filterv #(contains? (:pages perms) (:page-id %)) threads)
|
||||
threads)))))
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(get-comment-threads conn profile-id file-id))))
|
||||
|
||||
(defn- get-comment-threads-sql
|
||||
[where]
|
||||
@@ -332,15 +329,9 @@
|
||||
::sm/params schema:get-comment-thread}
|
||||
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
thread (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
|
||||
(decode-row))]
|
||||
(when (and thread (= :share-link (:type perms)))
|
||||
(when-not (contains? (:pages perms) (:page-id thread))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found")))
|
||||
thread))))
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
|
||||
(decode-row)))))
|
||||
|
||||
;; --- COMMAND: Retrieve Comments
|
||||
|
||||
@@ -357,13 +348,8 @@
|
||||
::sm/params schema:get-comments}
|
||||
[cfg {:keys [::rpc/profile-id thread-id share-id]}]
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [{:keys [file-id page-id]} (get-comment-thread conn thread-id)
|
||||
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
|
||||
(when (and (= :share-link (:type perms))
|
||||
(not (contains? (:pages perms) page-id)))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))
|
||||
(let [{:keys [file-id]} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||
(get-comments conn thread-id)))))
|
||||
|
||||
(def sql:get-comments
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
(ns app.rpc.commands.demo
|
||||
"A demo specific mutations."
|
||||
(:require
|
||||
[app.auth :refer [derive-password-weak]]
|
||||
[app.auth :refer [derive-password]]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
@@ -18,33 +17,25 @@
|
||||
[app.rpc.commands.auth :as auth]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]
|
||||
[buddy.core.codecs :as bc]
|
||||
[buddy.core.nonce :as bn]))
|
||||
|
||||
(def ^:private
|
||||
schema:create-demo-profile
|
||||
[:map
|
||||
[:skip-onboarding {:optional true} ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::create-demo-profile
|
||||
"A command that is responsible of creating a demo purpose
|
||||
profile. It only works if the `demo-users` flag is enabled in the
|
||||
configuration."
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"
|
||||
::doc/changes [["1.15" "This method is migrated from mutations to commands."]
|
||||
["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]]
|
||||
::sm/params schema:create-demo-profile}
|
||||
[cfg {:keys [skip-onboarding]}]
|
||||
::doc/changes ["1.15" "This method is migrated from mutations to commands."]}
|
||||
[cfg _]
|
||||
|
||||
(when-not (contains? cf/flags :demo-users)
|
||||
(ex/raise :type :validation
|
||||
:code :demo-users-not-allowed
|
||||
:hint "Demo users are disabled by config."))
|
||||
|
||||
(let [sem (uuid/next)
|
||||
email (str "demo-" sem "@demo.example.com")
|
||||
(let [sem (System/currentTimeMillis)
|
||||
email (str "demo-" sem ".demo@example.com")
|
||||
fullname (str "Demo User " sem)
|
||||
|
||||
password (-> (bn/random-bytes 16)
|
||||
@@ -55,23 +46,13 @@
|
||||
:fullname fullname
|
||||
:is-active true
|
||||
:is-demo true
|
||||
:password (derive-password-weak password)
|
||||
:props (cond-> {}
|
||||
skip-onboarding (assoc :onboarding-viewed true
|
||||
;; Redundant today: auth/create-profile
|
||||
;; overwrites this with the current
|
||||
;; version, kept so the skip does not
|
||||
;; depend on that default.
|
||||
:release-notes-viewed (:main cf/version)))}
|
||||
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
||||
:password (derive-password password)
|
||||
:props {}}
|
||||
profile (db/tx-run! cfg (fn [cfg]
|
||||
(->> (auth/create-profile cfg params)
|
||||
(auth/create-profile-rels cfg))))]
|
||||
|
||||
(wrk/submit! (-> cfg
|
||||
(assoc ::wrk/task :demo-purge)
|
||||
(assoc ::wrk/delay (cf/get-deletion-delay))
|
||||
(assoc ::wrk/params {:profile-id (:id profile)})))
|
||||
|
||||
(with-meta {:email email
|
||||
:password password}
|
||||
{::audit/profile-id (:id profile)})))
|
||||
|
||||
@@ -95,23 +95,18 @@
|
||||
(def check-read-permissions!
|
||||
(perms/make-check-fn has-read-permissions?))
|
||||
|
||||
;; A user has comment permissions if:
|
||||
;; - For :membership type: they have read permissions OR explicit comment permissions
|
||||
;; - For :share-link type: they must have explicit comment permissions (who-comment=all)
|
||||
;; This prevents share-link holders with who-comment=team from bypassing the restriction
|
||||
;; A user has comment permissions if she has read permissions, or
|
||||
;; explicit comment permissions through the share-id
|
||||
|
||||
(defn check-comment-permissions!
|
||||
[cfg profile-id file-id share-id]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
|
||||
allowed? (if (= :share-link (:type perms))
|
||||
(has-comment-permissions? perms)
|
||||
(or (has-read-permissions? perms)
|
||||
(has-comment-permissions? perms)))]
|
||||
(when-not allowed?
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
|
||||
can-read (has-read-permissions? perms)
|
||||
can-comment (has-comment-permissions? perms)]
|
||||
(when-not (or can-read can-comment)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))
|
||||
perms))
|
||||
:hint "not found"))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUERY COMMANDS
|
||||
@@ -241,18 +236,6 @@
|
||||
(some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"})
|
||||
(update :data blob/decode)))
|
||||
|
||||
(defn- check-fragment-scope!
|
||||
"Checks that the fragment is reachable from the pages authorized by
|
||||
the share-link. Raises a :not-found exception if the fragment is not reachable."
|
||||
[cfg file-id fragment-id pages]
|
||||
(let [fdata (-> (bfc/get-file cfg file-id :read-only? true)
|
||||
(get :data)
|
||||
(update :pages-index select-keys pages))]
|
||||
(when-not (contains? (feat.fdata/get-used-pointer-ids fdata) fragment-id)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "object not found"))))
|
||||
|
||||
(sv/defmethod ::get-file-fragment
|
||||
"Retrieve a file fragment by its ID. Only authenticated users."
|
||||
{::doc/added "1.17"
|
||||
@@ -263,8 +246,6 @@
|
||||
(db/run! cfg (fn [cfg]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
|
||||
(check-read-permissions! perms)
|
||||
(when (= :share-link (:type perms))
|
||||
(check-fragment-scope! cfg file-id fragment-id (:pages perms)))
|
||||
(-> (get-file-fragment cfg file-id fragment-id)
|
||||
(rph/with-http-cache long-cache-duration))))))
|
||||
|
||||
@@ -411,14 +392,6 @@
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
|
||||
file (bfc/get-file cfg file-id :read-only? true)
|
||||
|
||||
resolved-page-id (or page-id (-> file :data :pages first))
|
||||
|
||||
_ (when (and (= :share-link (:type perms))
|
||||
(not (contains? (:pages perms) resolved-page-id)))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "object not found"))
|
||||
|
||||
proj (db/get conn :project {:id (:project-id file)})
|
||||
|
||||
team (-> (db/get conn :team {:id (:team-id proj)})
|
||||
@@ -429,7 +402,8 @@
|
||||
(cfeat/check-file-features! (:features file)))
|
||||
|
||||
page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
|
||||
(let [page (dm/get-in file [:data :pages-index resolved-page-id])]
|
||||
(let [page-id (or page-id (-> file :data :pages first))
|
||||
page (dm/get-in file [:data :pages-index page-id])]
|
||||
(if (pmap/pointer-map? page)
|
||||
(deref page)
|
||||
page)))]
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
(ns app.rpc.commands.files-share
|
||||
"Share link related rpc mutation methods."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
@@ -45,7 +43,7 @@
|
||||
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
|
||||
(let [pages (db/create-array conn "uuid" pages)
|
||||
slink (db/insert! conn :share-link
|
||||
{:id (uuid/random)
|
||||
{:id (uuid/next)
|
||||
:file-id file-id
|
||||
:who-comment who-comment
|
||||
:who-inspect who-inspect
|
||||
@@ -68,16 +66,5 @@
|
||||
[{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}]
|
||||
(let [slink (db/get-by-id conn :share-link id)]
|
||||
(files/check-edition-permissions! conn profile-id (:file-id slink))
|
||||
|
||||
;; Verify caller owns this specific share-link, OR has admin access.
|
||||
;; Note: :is-admin already includes :is-owner (see bfc/get-file-permissions),
|
||||
;; so we only need to check :is-admin here.
|
||||
(let [perms (bfc/get-file-permissions conn profile-id (:file-id slink))]
|
||||
(when-not (or (= (:owner-id slink) profile-id)
|
||||
(:is-admin perms))
|
||||
(ex/raise :type :authorization
|
||||
:code :not-share-link-owner
|
||||
:hint "You can only delete share-links you created")))
|
||||
|
||||
(db/delete! conn :share-link {:id id})
|
||||
nil))
|
||||
@@ -93,18 +93,6 @@
|
||||
|
||||
(declare create-font-variant)
|
||||
|
||||
(defn- check-font-team-ownership!
|
||||
"When font-id already has variants belonging to a different team,
|
||||
raises :not-found to prevent cross-team font injection."
|
||||
[conn team-id font-id]
|
||||
(let [row (db/get* conn :team-font-variant
|
||||
{:font-id font-id}
|
||||
{::db/columns [:team-id]})]
|
||||
(when (and row (not= (:team-id row) team-id))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "font does not belong to this team"))))
|
||||
|
||||
(def ^:private schema:create-font-variant
|
||||
[:map {:title "create-font-variant"}
|
||||
[:team-id ::sm/uuid]
|
||||
@@ -118,10 +106,10 @@
|
||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||
session-id}` map) into a temp file, validates the media type and
|
||||
size of every entry, and returns a `{mtype → path}` data map."
|
||||
[cfg {:keys [::rpc/profile-id uploads] :as params}]
|
||||
[cfg {:keys [uploads] :as params}]
|
||||
(let [data (reduce-kv
|
||||
(fn [acc mtype session-id]
|
||||
(let [assembled (assemble-chunks cfg profile-id session-id)]
|
||||
(let [assembled (assemble-chunks cfg session-id)]
|
||||
(-> {:mtype mtype :size (:size assembled)}
|
||||
(media.v/validate-media-type! cm/font-types)
|
||||
(media.v/validate-font-size!))
|
||||
@@ -144,9 +132,8 @@
|
||||
[:process-font/global]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-font-variant}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(check-font-team-ownership! pool team-id font-id)
|
||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
@@ -353,7 +340,7 @@
|
||||
::sto/touched-at (ct/in-future {:minutes 30})
|
||||
:profile-id profile-id
|
||||
:content-type mtype
|
||||
:bucket sto/tempfile-bucket}]
|
||||
:bucket "tempfile"}]
|
||||
|
||||
(sto/put-object! storage content)))
|
||||
|
||||
|
||||
@@ -426,9 +426,7 @@
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/input template)
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
|
||||
(assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)))
|
||||
|
||||
result (if (= format :binfile-v3)
|
||||
(bf.v3/import-files! cfg)
|
||||
|
||||
@@ -40,12 +40,6 @@
|
||||
|
||||
(declare create-file-media-object)
|
||||
|
||||
(def ^:private sql:get-team-id-for-file
|
||||
"SELECT p.team_id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE f.id = ?")
|
||||
|
||||
(def ^:private schema:upload-file-media-object
|
||||
[:map {:title "upload-file-media-object"}
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
@@ -64,12 +58,6 @@
|
||||
(media.v/validate-media-type! content)
|
||||
(media.v/validate-media-size! content)
|
||||
|
||||
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
|
||||
(quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id
|
||||
::quotes/incr (:size content)}))
|
||||
|
||||
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
;; We get the minimal file for proper checking if
|
||||
;; file is not already deleted
|
||||
@@ -284,13 +272,8 @@
|
||||
(clone-file-media-object cfg params))
|
||||
|
||||
(defn clone-file-media-object
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}]
|
||||
[{:keys [::db/conn]} {:keys [id file-id is-local]}]
|
||||
(let [mobj (db/get-by-id conn :file-media-object id)]
|
||||
(when-not mobj
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "source media object not found"))
|
||||
(files/check-read-permissions! conn (::rpc/profile-id params) (:file-id mobj))
|
||||
(db/insert! conn :file-media-object
|
||||
{:id (uuid/next)
|
||||
:file-id file-id
|
||||
@@ -306,7 +289,7 @@
|
||||
|
||||
(def ^:private schema:create-upload-session
|
||||
[:map {:title "create-upload-session"}
|
||||
[:total-chunks [::sm/int {:min 1}]]])
|
||||
[:total-chunks ::sm/int]])
|
||||
|
||||
(def ^:private schema:create-upload-session-result
|
||||
[:map {:title "create-upload-session-result"}
|
||||
@@ -379,7 +362,7 @@
|
||||
::sto/deduplicate? false
|
||||
::sto/touch true
|
||||
:content-type (:mtype content)
|
||||
:bucket sto/tempfile-bucket
|
||||
:bucket "tempfile"
|
||||
:upload-id (str session-id)
|
||||
:chunk-index index}))
|
||||
|
||||
@@ -419,10 +402,9 @@
|
||||
|
||||
Raises a :validation/:missing-chunks error when the number of stored
|
||||
chunks does not match `:total-chunks` recorded in the session row.
|
||||
Raises :not-found when the session does not belong to `profile-id`.
|
||||
Deletes the session row from `upload_session` on success."
|
||||
[{:keys [::db/conn] :as cfg} profile-id session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
|
||||
[{:keys [::db/conn] :as cfg} session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id})
|
||||
chunks (get-upload-chunks conn session-id)]
|
||||
|
||||
(when (not= (count chunks) (:total-chunks session))
|
||||
@@ -465,7 +447,7 @@
|
||||
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [content (assemble-chunks cfg profile-id session-id)
|
||||
(let [content (assemble-chunks cfg session-id)
|
||||
content (-> content
|
||||
(assoc :filename (str "upload:" name))
|
||||
(assoc :mtype mtype)
|
||||
|
||||
@@ -41,6 +41,17 @@
|
||||
(ex/raise :type :validation
|
||||
:code :cant-move-default-team))))
|
||||
|
||||
(defn assert-membership [cfg profile-id organization-id]
|
||||
(let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id
|
||||
:organization-id organization-id})]
|
||||
(when-not (:organization-id membership)
|
||||
(ex/raise :type :validation
|
||||
:code :organization-does-not-exist))
|
||||
|
||||
(when-not (:is-member membership)
|
||||
(ex/raise :type :validation
|
||||
:code :user-doesnt-belong-organization))))
|
||||
|
||||
|
||||
(def schema:connectivity
|
||||
[:map {:title "nitrate-connectivity"}
|
||||
@@ -48,7 +59,7 @@
|
||||
|
||||
(sv/defmethod ::get-nitrate-connectivity
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:connectivity}
|
||||
[cfg _params]
|
||||
@@ -64,7 +75,7 @@
|
||||
|
||||
(sv/defmethod ::get-subscription-warning
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:subscription-warning}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
@@ -80,7 +91,7 @@
|
||||
|
||||
(sv/defmethod ::redeem-nitrate-activation-code
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.14"
|
||||
::sm/params schema:redeem-activation-code-params
|
||||
::sm/result schema:redeem-activation-code-result}
|
||||
[cfg {:keys [::rpc/profile-id activation-code]}]
|
||||
@@ -101,7 +112,6 @@
|
||||
(ex/raise :type :validation
|
||||
:code (case status
|
||||
410 :expired-activation-code
|
||||
409 :used-activation-code
|
||||
:invalid-activation-code)
|
||||
:cause cause)
|
||||
(throw cause)))))))
|
||||
@@ -113,7 +123,7 @@
|
||||
"Returns a Base64-encoded JSON file requesting a Nitrate activation code.
|
||||
Payload includes nitrateId, publicKey, email and iat."
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.20"
|
||||
::sm/params [:map]
|
||||
::sm/result ::sm/text}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
@@ -325,7 +335,7 @@
|
||||
(when-not skip-validation
|
||||
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
||||
|
||||
(nitrate/assert-membership cfg profile-id id)
|
||||
(assert-membership cfg profile-id id)
|
||||
|
||||
;; delete only eligible teams (non-protected and without files)
|
||||
(doseq [id deletable-team-ids]
|
||||
@@ -336,7 +346,7 @@
|
||||
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
||||
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
||||
|
||||
;; Process organization "Personal Projects" team: keep with prefix if needed, otherwise delete.
|
||||
;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
(when default-team-id
|
||||
(if keep-default-team?
|
||||
(db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id])
|
||||
@@ -361,7 +371,7 @@
|
||||
|
||||
(sv/defmethod ::leave-organization
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.15"
|
||||
::sm/params schema:leave-organization
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
@@ -405,13 +415,13 @@
|
||||
[:organization-name ::sm/text]])
|
||||
|
||||
(sv/defmethod ::remove-team-from-organization
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:remove-team-from-organization}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}]
|
||||
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
(assert-not-default-team cfg team-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
;; Check moveTeams permission on the source organization
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
@@ -458,14 +468,13 @@
|
||||
(let [emails (map :email (noh/get-team-invitation-emails conn team-id))]
|
||||
(if (empty? emails)
|
||||
{:allows-anybody false :external-emails []}
|
||||
(let [emails-array (db/create-array conn "text" (vec emails))
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
(let [emails-array (db/create-array conn "text" (vec emails))
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||
member-emails (->> profiles
|
||||
(filter #(contains? organization-member-ids (:id %)))
|
||||
(map :email)
|
||||
(into #{}))
|
||||
external-emails (into [] (remove member-emails emails))]
|
||||
external-emails (->> profiles
|
||||
(remove #(contains? organization-member-ids (:id %)))
|
||||
(map :email)
|
||||
(vec))]
|
||||
{:allows-anybody false :external-emails external-emails}))))))
|
||||
|
||||
(def ^:private schema:add-team-to-organization
|
||||
@@ -475,14 +484,14 @@
|
||||
|
||||
(sv/defmethod ::add-team-to-organization
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:add-team-to-organization
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
|
||||
(assert-is-owner cfg profile-id team-id)
|
||||
(assert-not-default-team cfg team-id)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
|
||||
(when (contains? cf/flags :admin-console)
|
||||
(let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id}))
|
||||
@@ -560,13 +569,13 @@
|
||||
|
||||
(sv/defmethod ::check-organization-members
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:check-organization-members-params
|
||||
::sm/result [:map-of :string :boolean]
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
|
||||
(or (when (contains? cf/flags :admin-console)
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [emails-array (db/create-array conn "text" emails)
|
||||
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
|
||||
@@ -585,7 +594,7 @@
|
||||
|
||||
(sv/defmethod ::all-organization-members-in-team
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-organization-members-in-team-params
|
||||
::sm/result ::sm/boolean}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
@@ -594,7 +603,7 @@
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id})
|
||||
organization-member-ids (into #{} organization-members)
|
||||
team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
@@ -609,7 +618,7 @@
|
||||
|
||||
(sv/defmethod ::all-team-members-in-organizations
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:all-team-members-in-organizations-params
|
||||
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
|
||||
@@ -622,7 +631,7 @@
|
||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
|
||||
team-member-ids (into #{} (map :profile-id team-members))]
|
||||
;; Validate requester membership in all organizations before fetching members.
|
||||
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
|
||||
(run! #(assert-membership cfg profile-id %) organization-ids)
|
||||
|
||||
(into {}
|
||||
(map (fn [organization-id]
|
||||
@@ -645,7 +654,7 @@
|
||||
|
||||
(sv/defmethod ::check-team-external-invitations
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.17"
|
||||
::sm/params schema:check-team-external-invitations-params
|
||||
::sm/result schema:check-team-external-invitations-result
|
||||
::db/transaction true}
|
||||
@@ -655,7 +664,7 @@
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
|
||||
{:has-external-invitations (boolean (seq external-emails))
|
||||
:allows-anybody allows-anybody}))
|
||||
@@ -674,17 +683,12 @@
|
||||
(sv/defmethod ::check-nitrate-sso
|
||||
"Check if a user needs to login into the organization SSO.
|
||||
Accepts either team-id (to look up the organization via the team) or organization-id directly.
|
||||
Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the
|
||||
session already holds a valid entry for the organization, and
|
||||
{:authorized true :reason :no-team-access} when the gate was skipped because the
|
||||
user cannot access the team; the reason lets the client tell a usable session
|
||||
apart from a plain permission failure.
|
||||
Returns {:authorized true} when SSO is not active or the user cannot access the team.
|
||||
Returns {:authorized false :redirect-uri <url>} when SSO is active;
|
||||
the client must redirect there. The OIDC provider itself handles
|
||||
re-authentication transparently if the user already has an active SSO session.
|
||||
A nil :redirect-uri means SSO is required but the provider is not usable."
|
||||
re-authentication transparently if the user already has an active SSO session."
|
||||
{::rpc/auth true
|
||||
::doc/added "2.18"
|
||||
::doc/added "2.19"
|
||||
::sm/params schema:check-nitrate-sso
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
|
||||
@@ -693,25 +697,16 @@
|
||||
(not (teams/has-read-permissions? cfg profile-id team-id)))
|
||||
;; Let the destination RPC enforce its own permissions. Starting SSO before
|
||||
;; access is established sends unrelated users through the organization's IdP.
|
||||
{:authorized true :reason :no-team-access}
|
||||
{:authorized true}
|
||||
(let [request (rph/get-request params)
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
||||
(if authorized
|
||||
{:authorized true :reason :sso-satisfied}
|
||||
{:authorized true}
|
||||
(if (oidc/organization-sso-discovery-uri sso)
|
||||
(try
|
||||
(let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri
|
||||
cfg sso
|
||||
:dest-url url
|
||||
:organization-id organization-id)
|
||||
organization-id (or organization-id (:organization-id sso))]
|
||||
(oidc/submit-organization-sso-auth-started-event
|
||||
cfg request profile-id organization-id)
|
||||
{:authorized false :redirect-uri redirect-uri})
|
||||
(catch Throwable cause
|
||||
(oidc/submit-organization-sso-auth-failed-event
|
||||
cfg request profile-id (or organization-id (:organization-id sso)) cause)
|
||||
(throw cause)))
|
||||
{:authorized false
|
||||
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
|
||||
:dest-url url
|
||||
:organization-id organization-id)}
|
||||
{:authorized false
|
||||
:redirect-uri nil}))))
|
||||
{:authorized true :reason :sso-satisfied}))
|
||||
{:authorized true}))
|
||||
@@ -141,7 +141,9 @@
|
||||
(defn get-profile
|
||||
"Get profile by id. Throws not-found exception if no profile found."
|
||||
[conn id & {:as opts}]
|
||||
(-> (db/get-by-id conn :profile id opts)
|
||||
;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
|
||||
;; are created with a set deleted-at value
|
||||
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
|
||||
(decode-row)))
|
||||
|
||||
;; --- MUTATION: Update Profile (own)
|
||||
@@ -164,12 +166,8 @@
|
||||
;; the same row/object.
|
||||
(let [profile (get-profile conn profile-id ::db/for-update true)
|
||||
fullname (d/normalize-string fullname)
|
||||
lang (if (contains? params :lang)
|
||||
(d/normalize-string lang)
|
||||
(:lang profile))
|
||||
theme (if (contains? params :theme)
|
||||
(d/normalize-string theme)
|
||||
(:theme profile))
|
||||
lang (d/normalize-string lang)
|
||||
theme (d/normalize-string theme)
|
||||
;; Update the profile map with direct params
|
||||
profile (-> profile
|
||||
(assoc :fullname fullname)
|
||||
@@ -522,7 +520,7 @@
|
||||
;; Penpot back through two paths: ::notify-user-organizations-deletion
|
||||
;; (during delete-owned-organizations) and ::notify-organization-deletion.
|
||||
;; Both preserve organization teams unchanged and only prefix or delete
|
||||
;; imported "Personal Projects" teams according to whether they still have files.
|
||||
;; imported "Your Penpot" teams according to whether they still have files.
|
||||
;; Let Nitrate clean up the data associated with the deleted Penpot user:
|
||||
;; owned organizations, remaining memberships, and subscription cancellation.
|
||||
(when (contains? cf/flags :admin-console)
|
||||
@@ -536,10 +534,6 @@
|
||||
:deleted-at deleted-at
|
||||
:id profile-id}})
|
||||
|
||||
;; Invalidate all sessions for this profile to ensure immediate
|
||||
;; access revocation across all devices
|
||||
(session/invalidate-all cfg profile-id)
|
||||
|
||||
(-> (rph/wrap nil)
|
||||
(rph/with-transform (session/delete-fn cfg)))))
|
||||
|
||||
|
||||
@@ -196,11 +196,11 @@
|
||||
::sm/params schema:get-teams}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(let [teams (get-teams conn profile-id)]
|
||||
(if (contains? cf/flags :admin-console)
|
||||
(->> (nitrate/add-organization-info-to-teams cfg teams params)
|
||||
(remove #(get-in % [:organization :expired-license])))
|
||||
teams))))
|
||||
(cond->> (get-teams conn profile-id)
|
||||
(contains? cf/flags :admin-console)
|
||||
(map #(nitrate/add-organization-info-to-team cfg % params))
|
||||
(contains? cf/flags :admin-console)
|
||||
(remove #(get-in % [:organization :expired-license])))))
|
||||
|
||||
(def ^:private sql:get-owned-teams
|
||||
"SELECT t.id, t.name,
|
||||
@@ -538,9 +538,6 @@
|
||||
;; When creating inside an organization, verify the user has permission to do so.
|
||||
;; Fail closed: if organization permissions cannot be fetched, deny the operation.
|
||||
(when (and organization-id (contains? cf/flags :admin-console))
|
||||
;; Verify caller is a member of the organization
|
||||
(nitrate/assert-membership cfg profile-id organization-id)
|
||||
|
||||
(let [organization-perms (nitrate/call cfg :get-organization-permissions
|
||||
{:organization-id organization-id})]
|
||||
(if (nil? organization-perms)
|
||||
@@ -575,7 +572,7 @@
|
||||
(set/difference cfeat/frontend-only-features)
|
||||
(set/difference cfeat/no-team-inheritable-features))
|
||||
params {:profile-id profile-id
|
||||
:name "Personal Projects"
|
||||
:name "Your Penpot"
|
||||
:features features
|
||||
:organization-id organization-id
|
||||
:is-default true}
|
||||
@@ -829,7 +826,7 @@
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
;; Protect the user's personal default team from deletion.
|
||||
;; Organization-scoped default teams ("Personal Projects") are allowed to be deleted when they have no files.
|
||||
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||
(when (and (:is-default team) (not in-organization?))
|
||||
(ex/raise :type :validation
|
||||
:code :non-deletable-team
|
||||
@@ -944,10 +941,8 @@
|
||||
::sm/params schema:delete-team-member
|
||||
::db/transaction true}
|
||||
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}]
|
||||
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||
perms (get-permissions conn profile-id team-id)
|
||||
members (get-team-members conn team-id)
|
||||
member (d/seek #(= member-id (:id %)) members)]
|
||||
(let [team (get-team conn :profile-id profile-id :team-id team-id)
|
||||
perms (get-permissions conn profile-id team-id)]
|
||||
(when-not (or (:is-owner perms)
|
||||
(:is-admin perms))
|
||||
(ex/raise :type :validation
|
||||
@@ -957,15 +952,6 @@
|
||||
(ex/raise :type :validation
|
||||
:code :cant-remove-yourself))
|
||||
|
||||
(when-not member
|
||||
(ex/raise :type :not-found
|
||||
:code :member-does-not-exist))
|
||||
|
||||
(when (and (:is-owner member)
|
||||
(not (:is-owner perms)))
|
||||
(ex/raise :type :validation
|
||||
:code :cant-remove-owner))
|
||||
|
||||
(db/delete! conn :team-profile-rel {:profile-id member-id
|
||||
:team-id team-id})
|
||||
|
||||
|
||||
@@ -108,7 +108,14 @@
|
||||
(def ^:private schema:create-organization-invitation
|
||||
[:map {:title "params:create-organization-invitation"}
|
||||
[::rpc/profile-id ::sm/uuid]
|
||||
[:organization cto/schema:organization-with-avatar]
|
||||
[:organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo ::sm/uri]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
||||
[:sso-active [:maybe ::sm/boolean]]]]
|
||||
[:profile
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
@@ -235,8 +242,9 @@
|
||||
:organization-name (:name organization)
|
||||
:member-email (:email-to invitation)
|
||||
:member-id (:id member)
|
||||
:role role
|
||||
:user-who-send-invitation (str profile-id)}
|
||||
:role role}
|
||||
organization
|
||||
(assoc :user-who-send-invitation (str profile-id))
|
||||
|
||||
(not organization)
|
||||
(assoc :team-belongs-to-organization (boolean team-organization-id)
|
||||
@@ -459,10 +467,6 @@
|
||||
[cfg {:keys [::rpc/profile-id team-id role emails] :as params}]
|
||||
(let [perms (teams/get-permissions cfg profile-id team-id)
|
||||
profile (db/get-by-id cfg :profile profile-id)
|
||||
team (db/get-by-id cfg :team team-id)
|
||||
team-with-org (when (contains? cf/flags :admin-console)
|
||||
(nitrate/add-organization-info-to-team cfg team {}))
|
||||
organization (:organization team-with-org)
|
||||
;; Determine which format is being used
|
||||
using-emails-format? (and emails role)
|
||||
;; Handle both parameter formats
|
||||
@@ -478,24 +482,6 @@
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
(when (and (contains? cf/flags :admin-console)
|
||||
organization
|
||||
(not (cto/allowed? :send-invitations
|
||||
{:organization-perms {:owner-id (:owner-id organization)
|
||||
:permissions (:permissions organization)}
|
||||
:profile-id profile-id
|
||||
:team-perms perms})))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions
|
||||
:hint "Organization policy does not allow you to send invitations"))
|
||||
|
||||
;; Don't allow promote to owner to admin users.
|
||||
(when (and (not (:is-owner perms))
|
||||
(or (= role :owner)
|
||||
(some #(= :owner (:role %)) (:invitations params))))
|
||||
(ex/raise :type :validation
|
||||
:code :cant-promote-to-owner))
|
||||
|
||||
(when (> invitation-count max-invitations-by-request-threshold)
|
||||
(ex/raise :type :validation
|
||||
:code :max-invitations-by-request
|
||||
@@ -602,7 +588,7 @@
|
||||
::doc/module :teams
|
||||
::sm/params schema:get-team-invitation-token}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}]
|
||||
(teams/check-edition-permissions! cfg profile-id team-id)
|
||||
(teams/check-read-permissions! cfg profile-id team-id)
|
||||
(let [email (profile/clean-email email)
|
||||
invit (-> (db/get pool :team-invitation
|
||||
{:team-id team-id
|
||||
@@ -639,11 +625,6 @@
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
;; Don't allow promote to owner to admin users.
|
||||
(when (and (not (:is-owner perms)) (= role :owner))
|
||||
(ex/raise :type :validation
|
||||
:code :cant-promote-to-owner))
|
||||
|
||||
(db/update! conn :team-invitation
|
||||
{:role (name role) :updated-at (ct/now)}
|
||||
{:team-id team-id :email-to (profile/clean-email email)})
|
||||
|
||||
@@ -308,9 +308,7 @@
|
||||
(assoc :name "accept-organization-invitation")
|
||||
(assoc :props
|
||||
(-> props
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:user-id (:id profile)
|
||||
:user-who-send-invitation (:created-by invitation))
|
||||
(assoc :organization-id organization-id-on-add)
|
||||
(audit/clean-props))))))
|
||||
|
||||
(cond-> (assoc claims :state :created)
|
||||
@@ -327,8 +325,6 @@
|
||||
(assoc :organization-id organization-id-on-add
|
||||
:organization-member-add-source organization-add-source
|
||||
:belongs-to-team-on-add (boolean team-id)
|
||||
:user-id (:id profile)
|
||||
:user-who-send-invitation (:created-by invitation)
|
||||
:organization-member-count-before
|
||||
organization-member-count-before)
|
||||
(audit/clean-props))}))))))
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
(assoc :can-read true)))
|
||||
|
||||
(defn- get-view-only-bundle
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id ::perms] :as params}]
|
||||
(let [file (bfc/get-file cfg file-id)
|
||||
|
||||
project (db/get conn :project
|
||||
@@ -89,18 +89,16 @@
|
||||
(mapv (fn [{:keys [id] :as lib}]
|
||||
(merge lib (bfc/get-file cfg id)))))
|
||||
|
||||
links (cond->> (->> (db/query conn :share-link {:file-id file-id})
|
||||
(mapv (fn [row]
|
||||
(-> row
|
||||
(update :pages db/decode-pgarray #{})
|
||||
;; NOTE: the flags are deprecated but are still present
|
||||
;; on the table on old rows. The flags are pgarray and
|
||||
;; for avoid decoding it (because they are no longer used
|
||||
;; on frontend) we just dissoc the column attribute from
|
||||
;; row.
|
||||
(dissoc :flags)))))
|
||||
(= :share-link (:type perms))
|
||||
(filterv #(= (:id %) share-id)))
|
||||
links (->> (db/query conn :share-link {:file-id file-id})
|
||||
(mapv (fn [row]
|
||||
(-> row
|
||||
(update :pages db/decode-pgarray #{})
|
||||
;; NOTE: the flags are deprecated but are still present
|
||||
;; on the table on old rows. The flags are pgarray and
|
||||
;; for avoid decoding it (because they are no longer used
|
||||
;; on frontend) we just dissoc the column attribute from
|
||||
;; row.
|
||||
(dissoc :flags)))))
|
||||
|
||||
fonts (db/query conn :team-font-variant
|
||||
{:team-id (:id team)
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
|
||||
(ns app.rpc.management.exporter
|
||||
(:require
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.media.validation :as media.v]
|
||||
[app.media.validation :refer [schema:upload]]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.storage :as sto]
|
||||
@@ -22,7 +21,7 @@
|
||||
(def ^:private
|
||||
schema:upload-tempfile-params
|
||||
[:map {:title "upload-templfile-params"}
|
||||
[:content media.v/schema:upload]])
|
||||
[:content schema:upload]])
|
||||
|
||||
(def ^:private
|
||||
schema:upload-tempfile-result
|
||||
@@ -33,7 +32,6 @@
|
||||
::sm/params schema:upload-tempfile-params
|
||||
::sm/result schema:upload-tempfile-result}
|
||||
[cfg {:keys [::rpc/profile-id content]}]
|
||||
(media.v/validate-media-type! content cm/tempfile-types)
|
||||
(let [storage (sto/resolve cfg)
|
||||
hash (sto/calculate-hash (:path content))
|
||||
data (-> (sto/content (:path content))
|
||||
@@ -43,7 +41,7 @@
|
||||
::sto/touched-at (ct/in-future {:minutes 10})
|
||||
:profile-id profile-id
|
||||
:content-type (:mtype content)
|
||||
:bucket sto/tempfile-bucket}
|
||||
:bucket "tempfile"}
|
||||
object (sto/put-object! storage content)]
|
||||
{:id (:id object)
|
||||
:uri (-> (cf/get :public-uri)
|
||||
|
||||
@@ -12,13 +12,11 @@
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :as cto]
|
||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||
[app.common.types.team :refer [schema:team]]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -56,7 +54,7 @@
|
||||
|
||||
(sv/defmethod ::authenticate
|
||||
"Authenticate the current user"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
@@ -96,7 +94,7 @@
|
||||
|
||||
(sv/defmethod ::get-penpot-version
|
||||
"Get the current Penpot version"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-penpot-version-result
|
||||
::rpc/auth false}
|
||||
@@ -108,7 +106,7 @@
|
||||
|
||||
(sv/defmethod ::get-teams
|
||||
"List teams for which current user is owner"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-teams-result
|
||||
::nitrate/sso false}
|
||||
@@ -132,12 +130,11 @@
|
||||
"Store an organization logo in penpot storage and return its ID.
|
||||
Accepts an optional previous-id to mark the old logo for garbage
|
||||
collection when replacing an existing one."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:upload-organization-logo
|
||||
::sm/result schema:upload-organization-logo-result
|
||||
::nitrate/sso false}
|
||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||
(media.v/validate-media-type! content cm/image-types)
|
||||
(when previous-id
|
||||
(sto/touch-object! storage previous-id))
|
||||
(let [hash (sto/calculate-hash (:path content))
|
||||
@@ -154,7 +151,7 @@
|
||||
|
||||
(sv/defmethod ::notify-team-change
|
||||
"Notify to Penpot a team change from nitrate"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.14"
|
||||
::sm/params cto/schema:team-with-organization
|
||||
::rpc/auth false}
|
||||
[cfg team]
|
||||
@@ -171,7 +168,7 @@
|
||||
|
||||
(sv/defmethod ::notify-user-added-to-organization
|
||||
"Notify to Penpot that an user has joined an organization from nitrate"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.14"
|
||||
::sm/params schema:notify-user-added-to-organization
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [profile-id organization-id]}]
|
||||
@@ -202,7 +199,7 @@
|
||||
|
||||
(sv/defmethod ::get-managed-profiles
|
||||
"List profiles that belong to teams for which current user is owner"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.14"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:managed-profile-result
|
||||
::nitrate/sso false}
|
||||
@@ -242,7 +239,7 @@
|
||||
|
||||
(sv/defmethod ::get-teams-summary
|
||||
"Get summary information for a list of teams"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:get-teams-summary-params
|
||||
::sm/result schema:get-teams-summary-result
|
||||
::nitrate/sso false}
|
||||
@@ -318,7 +315,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a deleted organization, preserve organization teams unchanged and only prefix or
|
||||
delete member Personal Projects teams depending on whether they still contain files."
|
||||
delete member Your Penpot teams depending on whether they still contain files."
|
||||
[cfg {:keys [organization-id organization-name teams]}]
|
||||
(let [all-team-ids (->> teams
|
||||
(map :id)
|
||||
@@ -347,13 +344,13 @@ RETURNING id, deleted_at;")
|
||||
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
|
||||
|
||||
;; Organization teams move to the fallback organization unchanged. Only imported
|
||||
;; Personal Projects teams keep the organization prefix when they still have files.
|
||||
;; Your Penpot teams keep the organization prefix when they still have files.
|
||||
(when (seq teams-to-prefix)
|
||||
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||
organization-prefix
|
||||
(db/create-array conn "uuid" teams-to-prefix)]))
|
||||
|
||||
;; Empty imported Personal Projects teams disappear entirely.
|
||||
;; Empty imported Your Penpot teams disappear entirely.
|
||||
(soft-delete-teams! cfg teams-to-delete)
|
||||
|
||||
(notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete)
|
||||
@@ -362,8 +359,8 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::notify-organization-deletion
|
||||
"For a deleted organization, preserve organization teams and only prefix or delete
|
||||
imported Personal Projects before notifying connected users."
|
||||
{::doc/added "2.18"
|
||||
imported Your Penpot teams before notifying connected users."
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:notify-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
@@ -382,7 +379,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::notify-user-organizations-deletion
|
||||
"For a given user, find all owned organizations and apply the deleted-organization
|
||||
transfer rules to their imported Personal Projects teams."
|
||||
transfer rules to their imported Your Penpot teams."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-organizations-deletion
|
||||
::nitrate/sso false}
|
||||
@@ -409,7 +406,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::get-profile-by-email
|
||||
"Get profile by email"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map [:email ::sm/email]]
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
@@ -433,7 +430,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::get-profile-by-id
|
||||
"Get profile by email"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map [:id ::sm/uuid]]
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
@@ -468,7 +465,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::get-organization-member-team-counts
|
||||
"Get the number of non-default teams each profile belongs to within a set of teams."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:get-organization-member-team-counts-params
|
||||
::sm/result schema:get-organization-member-team-counts-result
|
||||
::rpc/auth false}
|
||||
@@ -491,33 +488,15 @@ RETURNING id, deleted_at;")
|
||||
|
||||
;; API: invite-to-organization
|
||||
|
||||
(defn- get-invitation-organization
|
||||
[cfg profile-id organization-id]
|
||||
(let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
|
||||
(nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
|
||||
(when-not (= profile-id owner-id)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "not found"))
|
||||
{:id id
|
||||
:name name
|
||||
:initials (if logo-id "" (d/get-initials name))
|
||||
:logo (when logo-id (u/uri (files/resolve-public-uri logo-id)))
|
||||
:avatar-bg-url (when-not logo-id avatar-bg-url)
|
||||
:sso-active (true? sso-active)}))
|
||||
|
||||
(sv/defmethod ::invite-to-organization
|
||||
"Invite to organization"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.15"
|
||||
::sm/params [:map
|
||||
[:email ::sm/email]
|
||||
[:organization cto/schema:organization-with-avatar]]
|
||||
::nitrate/sso false}
|
||||
[cfg {profile-id ::rpc/profile-id
|
||||
:keys [organization]
|
||||
:as params}]
|
||||
(let [organization (get-invitation-organization cfg profile-id (:id organization))]
|
||||
(db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
|
||||
[cfg params]
|
||||
(db/tx-run! cfg ti/create-organization-invitation params)
|
||||
nil)
|
||||
|
||||
|
||||
@@ -540,7 +519,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::get-organization-invitations
|
||||
"Get valid invitations for an organization, returning at most one invitation per email."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:get-organization-invitations-params
|
||||
::sm/result schema:get-organization-invitations-result
|
||||
::nitrate/sso false}
|
||||
@@ -568,7 +547,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::delete-organization-invitations
|
||||
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.16"
|
||||
::sm/params schema:delete-organization-invitations-params
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [organization-id email]}]
|
||||
@@ -633,7 +612,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::remove-from-organization
|
||||
"Remove an user from an organization"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.17"
|
||||
::sm/params [:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
@@ -678,7 +657,7 @@ RETURNING id, deleted_at;")
|
||||
(sv/defmethod ::get-remove-from-organization-summary
|
||||
"Get a summary of the teams that would be deleted, transferred, or exited
|
||||
if the user were removed from the organization"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.17"
|
||||
::sm/params [:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
@@ -713,7 +692,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::send-renewal-email
|
||||
"Send an Enterprise subscription renewal notice email to a user."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:send-renewal-email-params
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
|
||||
@@ -826,7 +805,7 @@ RETURNING id, deleted_at;")
|
||||
"Push audit events from nitrate (strictly for nitrate backend
|
||||
events)"
|
||||
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.19"
|
||||
::audit/skip true
|
||||
::sm/params schema:push-audit-events-params
|
||||
::rpc/auth false}
|
||||
@@ -933,7 +912,7 @@ RETURNING id, deleted_at;")
|
||||
(sv/defmethod ::get-teams-detail
|
||||
"Get detailed information for all non-deleted teams in an organization,
|
||||
including owner info and project/file/member counts."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.20"
|
||||
::sm/params schema:get-teams-detail-params
|
||||
::sm/result schema:get-teams-detail-result
|
||||
::nitrate/sso false}
|
||||
@@ -961,7 +940,7 @@ RETURNING id, deleted_at;")
|
||||
"Validate an organization SSO configuration by generating a login redirect URL.
|
||||
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
||||
discovery before saving the settings."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.20"
|
||||
::sm/params cto/schema:nitrate-sso
|
||||
::sm/result schema:check-organization-sso-result
|
||||
::rpc/auth false}
|
||||
@@ -971,7 +950,7 @@ RETURNING id, deleted_at;")
|
||||
;; ---- API: notify-organization-sso-change
|
||||
(sv/defmethod ::notify-organization-sso-change
|
||||
"Nitrate notifies that an organization sso values have changed"
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.19"
|
||||
::sm/params [:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:updated-props ::sm/boolean]
|
||||
@@ -1020,7 +999,7 @@ RETURNING id, deleted_at;")
|
||||
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
||||
email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled
|
||||
by default so it is only available on test environments."
|
||||
{::doc/added "2.18"
|
||||
{::doc/added "2.19"
|
||||
::sm/params schema:bulk-create-profiles-params
|
||||
::sm/result schema:bulk-create-profiles-result
|
||||
::rpc/auth false}
|
||||
@@ -1045,18 +1024,3 @@ RETURNING id, deleted_at;")
|
||||
(update acc :created conj email)))))
|
||||
{:created [] :skipped []}
|
||||
emails)))))
|
||||
|
||||
;; ---- API: get-air-gapped
|
||||
|
||||
(def ^:private schema:get-air-gapped-result
|
||||
[:map
|
||||
[:air-gapped ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::get-air-gapped
|
||||
"Returns whether this Penpot instance runs in air-gapped mode."
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-air-gapped-result
|
||||
::rpc/auth false}
|
||||
[_cfg _params]
|
||||
{:air-gapped (contains? cf/flags :air-gapped-conf)})
|
||||
@@ -546,76 +546,6 @@
|
||||
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
|
||||
(generic-check!)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:media-storage-bytes-per-team
|
||||
[:map
|
||||
[::profile-id ::sm/uuid]
|
||||
[::team-id ::sm/uuid]])
|
||||
|
||||
(def ^:private valid-media-storage-bytes-per-team-quote?
|
||||
(sm/lazy-validator schema:media-storage-bytes-per-team))
|
||||
|
||||
(def ^:private sql:get-media-storage-bytes-per-team
|
||||
"SELECT COALESCE(SUM(so.size), 0) AS total
|
||||
FROM (
|
||||
SELECT fmo.media_id AS so_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN file AS f ON (f.id = fmo.file_id)
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND fmo.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT fmo.thumbnail_id AS so_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN file AS f ON (f.id = fmo.file_id)
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND fmo.thumbnail_id IS NOT NULL
|
||||
AND fmo.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT v.otf_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.otf_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT v.ttf_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.ttf_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT v.woff1_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.woff1_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT v.woff2_file_id AS so_id
|
||||
FROM team_font_variant AS v
|
||||
WHERE v.team_id = ?
|
||||
AND v.woff2_file_id IS NOT NULL
|
||||
AND v.deleted_at IS NULL
|
||||
) AS refs
|
||||
JOIN storage_object AS so ON (so.id = refs.so_id)
|
||||
WHERE so.deleted_at IS NULL")
|
||||
|
||||
(defmethod check-quote ::media-storage-bytes-per-team
|
||||
[{:keys [::profile-id ::team-id ::target] :as quote}]
|
||||
(assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters")
|
||||
(-> quote
|
||||
(assoc ::default (cf/get :quotes-media-storage-bytes-per-team
|
||||
(* 20 1024 1024 1024)))
|
||||
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
|
||||
(assoc ::count-sql [sql:get-media-storage-bytes-per-team
|
||||
team-id team-id team-id team-id team-id team-id])
|
||||
(generic-check!)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; QUOTE: DEFAULT
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.math :as mth]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as uri]
|
||||
@@ -181,8 +180,8 @@
|
||||
result (rds/eval rconn script)
|
||||
allowed? (boolean (nth result 0))
|
||||
remaining (nth result 1)
|
||||
reset (long (mth/ceil (double (* (/ (inst-ms interval) rate)
|
||||
(- capacity remaining)))))]
|
||||
reset (* (/ (inst-ms interval) rate)
|
||||
(- capacity remaining))]
|
||||
(l/trace :hint "limit processed"
|
||||
:method method
|
||||
:limit (name (::name limit))
|
||||
@@ -191,7 +190,6 @@
|
||||
:allowed allowed?
|
||||
:remaining remaining)
|
||||
(-> limit
|
||||
(assoc ::lresult/now now)
|
||||
(assoc ::lresult/allowed allowed?)
|
||||
(assoc ::lresult/reset (ct/plus now reset))
|
||||
(assoc ::lresult/remaining remaining))))
|
||||
@@ -214,7 +212,6 @@
|
||||
:allowed allowed?
|
||||
:remaining remaining)
|
||||
(-> limit
|
||||
(assoc ::lresult/now now)
|
||||
(assoc ::lresult/allowed allowed?)
|
||||
(assoc ::lresult/timestamp ts)
|
||||
(assoc ::lresult/remaining remaining)
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
[:enum
|
||||
"customer_service"
|
||||
"low_quality"
|
||||
"missing_features"
|
||||
"missing_feature"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
(defn process-file!
|
||||
[system file-id update-fn
|
||||
& {:keys [::profile-id ::snapshot-label ::validate? ::with-libraries?]
|
||||
& {:keys [::snapshot-label ::validate? ::with-libraries?]
|
||||
:or {validate? true} :as opts}]
|
||||
(let [file (bfc/get-file system file-id
|
||||
:lock-for-update? true
|
||||
@@ -177,9 +177,8 @@
|
||||
(when (string? snapshot-label)
|
||||
(fsnap/create! system file
|
||||
{:label snapshot-label
|
||||
:profile-id profile-id
|
||||
:deleted-at (ct/in-future {:days 30})
|
||||
:created-by "system"}))
|
||||
:created-by "admin"}))
|
||||
|
||||
(let [file' (update file' :revn inc)]
|
||||
(bfc/update-file! system file' opts)
|
||||
|
||||
@@ -398,6 +398,10 @@
|
||||
(println (sm/humanize-explain explain))
|
||||
(ex/print-throwable cause))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PROCESSING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn repair-file!
|
||||
"Repair the list of errors detected by validation."
|
||||
[file-id & {:keys [rollback?] :or {rollback? true} :as options}]
|
||||
@@ -406,10 +410,6 @@
|
||||
options (assoc options ::h/with-libraries? true)]
|
||||
(db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PROCESSING
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn update-file!
|
||||
"Apply a function to the file. Optionally save the changes or not.
|
||||
The function receives the decoded and migrated file data."
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
(def default-bucket
|
||||
"file-media-object")
|
||||
|
||||
(def tempfile-bucket
|
||||
"Bucket name for temporary file uploads (10-minute expiry)."
|
||||
"tempfile")
|
||||
|
||||
(def valid-buckets
|
||||
#{"file-media-object"
|
||||
"team-font-variant"
|
||||
@@ -49,7 +45,7 @@
|
||||
"file-thumbnail"
|
||||
"profile"
|
||||
"organization"
|
||||
tempfile-bucket
|
||||
"tempfile"
|
||||
"file-data"
|
||||
"file-data-fragment"
|
||||
"file-change"})
|
||||
@@ -140,7 +136,7 @@
|
||||
result (when (and (::deduplicate? params)
|
||||
(:hash mdata)
|
||||
(:bucket mdata)
|
||||
(not= tempfile-bucket (:bucket mdata)))
|
||||
(not= "tempfile" (:bucket mdata)))
|
||||
(let [result (get-database-object-by-hash connectable backend
|
||||
(:bucket mdata)
|
||||
(:hash mdata))]
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
:status "delete"
|
||||
:bucket bucket)
|
||||
(recur to-freeze (conj to-delete id) (rest objects))))
|
||||
(let [deletion-delay (if (= sto/tempfile-bucket bucket)
|
||||
(let [deletion-delay (if (= "tempfile" bucket)
|
||||
(ct/duration {:hours 2})
|
||||
(cf/get-deletion-delay))]
|
||||
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
||||
@@ -158,16 +158,15 @@
|
||||
|
||||
(defn- process-bucket!
|
||||
[conn bucket objects]
|
||||
(cond
|
||||
(= bucket "file-media-object") (process-objects! conn has-file-media-object-refs? bucket objects)
|
||||
(= bucket "team-font-variant") (process-objects! conn has-team-font-variant-refs? bucket objects)
|
||||
(= bucket "file-object-thumbnail") (process-objects! conn has-file-object-thumbnails-refs? bucket objects)
|
||||
(= bucket "file-thumbnail") (process-objects! conn has-file-thumbnails-refs? bucket objects)
|
||||
(= bucket "profile") (process-objects! conn has-profile-refs? bucket objects)
|
||||
(= bucket "file-data") (process-objects! conn has-file-data-refs? bucket objects)
|
||||
(= bucket sto/tempfile-bucket) (process-objects! conn (constantly false) sto/tempfile-bucket objects)
|
||||
(= bucket "organization") (process-objects! conn (constantly false) bucket objects)
|
||||
:else
|
||||
(case bucket
|
||||
"file-media-object" (process-objects! conn has-file-media-object-refs? bucket objects)
|
||||
"team-font-variant" (process-objects! conn has-team-font-variant-refs? bucket objects)
|
||||
"file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? bucket objects)
|
||||
"file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects)
|
||||
"profile" (process-objects! conn has-profile-refs? bucket objects)
|
||||
"file-data" (process-objects! conn has-file-data-refs? bucket objects)
|
||||
"tempfile" (process-objects! conn (constantly false) bucket objects)
|
||||
"organization" (process-objects! conn (constantly false) bucket objects)
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-unknown-reference
|
||||
:hint (dm/fmt "unknown reference '%'" bucket))))
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.tasks.demo-purge
|
||||
"Task handler for delayed demo profile deletion. Submitted at demo
|
||||
creation time with a delay matching the configured deletion-delay."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.worker :as wrk]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(defmethod ig/assert-key ::handler
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [{:keys [props]}]
|
||||
(let [profile-id (get props :profile-id)
|
||||
now (ct/now)]
|
||||
|
||||
(l/trc :hint "demo-purge" :profile-id (str profile-id))
|
||||
|
||||
;; Mark the profile for immediate deletion
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(db/update! conn :profile
|
||||
{:deleted-at now}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
(wrk/submit!
|
||||
(-> cfg
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :profile
|
||||
:deleted-at now
|
||||
:id profile-id}))))))))
|
||||
@@ -13,7 +13,6 @@
|
||||
[app.db :as db]
|
||||
[app.features.fdata :as fdata]
|
||||
[app.storage :as sto]
|
||||
[app.tasks.delete-object :as dobj]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def ^:private sql:get-profiles
|
||||
@@ -34,11 +33,6 @@
|
||||
;; Mark as deleted the storage object
|
||||
(some->> photo-id (sto/touch-object! storage))
|
||||
|
||||
;; Cascade soft-delete to owned teams, projects, files, etc.
|
||||
(dobj/delete-object cfg {:object :profile
|
||||
:id id
|
||||
:deleted-at timestamp})
|
||||
|
||||
(let [affected (-> (db/delete! conn :profile {:id id})
|
||||
(db/get-update-count))]
|
||||
(+ total affected)))
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.util.ssrf
|
||||
"URL/host validation to prevent Server-Side Request Forgery.
|
||||
|
||||
The blocklist covers the standard JVM InetAddress classifications plus
|
||||
explicit ranges: IPv6 ULA, IPv4-mapped loopback, cloud metadata,
|
||||
operator-supplied CIDRs and the IPv6 transition mechanisms NAT64, 6to4
|
||||
and Teredo."
|
||||
"URL/host validation to prevent Server-Side Request Forgery."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
@@ -127,20 +122,6 @@
|
||||
;; Check the embedded IPv4 is loopback (127.x.x.x)
|
||||
(= (bit-and (aget bs 12) 0xFF) 127))))
|
||||
|
||||
(defn- transition-prefix
|
||||
"Classify a 16-byte IPv6 address into its transition mechanism:
|
||||
:nat64 (64:ff9b::/96), :6to4 (2002::/16), :teredo (2001:0000::/32) or nil."
|
||||
[^bytes bs]
|
||||
(let [b0 (bit-and (aget bs 0) 0xFF)
|
||||
b1 (bit-and (aget bs 1) 0xFF)
|
||||
b2 (bit-and (aget bs 2) 0xFF)
|
||||
b3 (bit-and (aget bs 3) 0xFF)]
|
||||
(cond
|
||||
(and (= b0 0x00) (= b1 0x64) (= b2 0xFF) (= b3 0x9B)) :nat64
|
||||
(and (= b0 0x20) (= b1 0x02)) :6to4
|
||||
(and (= b0 0x20) (= b1 0x01) (= b2 0x00) (= b3 0x00)) :teredo
|
||||
:else nil)))
|
||||
|
||||
(defn- blocked-address?
|
||||
"Check if an InetAddress should be blocked. Returns true if blocked."
|
||||
[^InetAddress addr]
|
||||
@@ -160,15 +141,12 @@
|
||||
;; Cloud metadata IPs (exact match)
|
||||
(contains? cloud-metadata-ips (.getHostAddress addr))
|
||||
|
||||
;; Extra blocked CIDRs (IPv4 only) and IPv6 transition mechanisms
|
||||
;; Extra blocked CIDRs (IPv4 only)
|
||||
(let [bs (.getAddress addr)]
|
||||
(if (= (alength bs) 4)
|
||||
(or (some #(in-cidr4? bs %) extra-blocked-ranges)
|
||||
(some #(in-cidr4? bs %) extra-blocked-cidrs))
|
||||
;; IPv6 transition mechanisms (NAT64/6to4/Teredo): the range is
|
||||
;; rejected outright.
|
||||
(boolean (when (= (alength bs) 16)
|
||||
(transition-prefix bs)))))))
|
||||
false))))
|
||||
|
||||
(defn resolve-host
|
||||
"Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
|
||||
@@ -185,10 +163,8 @@
|
||||
- host must resolve to at least one address, and
|
||||
- **every** resolved address must NOT be in the blocklist
|
||||
(loopback, link-local, site-local, multicast, any-local,
|
||||
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv6 transition
|
||||
mechanisms NAT64 64:ff9b::/96, 6to4 2002::/16 and Teredo
|
||||
2001:0000::/32, IPv4-mapped IPv6 of any blocked IPv4,
|
||||
plus operator-supplied CIDRs).
|
||||
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
|
||||
IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
|
||||
When the host is an IP literal (decimal/octal/hex/IPv6) it is
|
||||
normalized via `com.google.common.net.InetAddresses` before the
|
||||
check.
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
@@ -386,9 +385,6 @@
|
||||
(def ^:private test-profile-id
|
||||
#uuid "11111111-1111-1111-1111-111111111111")
|
||||
|
||||
(def ^:private test-organization-id
|
||||
#uuid "22222222-2222-2222-2222-222222222222")
|
||||
|
||||
(def ^:private test-profile
|
||||
{:id test-profile-id
|
||||
:is-active true
|
||||
@@ -523,59 +519,6 @@
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (.contains loc "error=unable-to-auth")))))))
|
||||
|
||||
(t/deftest organization-sso-callback-success-emits-succeeded
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
||||
:organization-id test-organization-id})
|
||||
request (default-request cfg :state state)
|
||||
events (atom [])]
|
||||
(with-redefs [app.nitrate/call (constantly {:active true})
|
||||
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
|
||||
app.auth.oidc/get-info (constantly {})
|
||||
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
||||
(let [result (#'oidc/callback-handler cfg request)]
|
||||
(t/is (= "https://penpot.example.com/#/workspace" (redirect-location result)))
|
||||
(t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events)))
|
||||
(t/is (= test-organization-id (get-in (first @events) [:props :organization-id])))))))
|
||||
|
||||
(t/deftest organization-sso-callback-error-emits-failed
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
||||
:organization-id test-organization-id})
|
||||
request (default-request cfg :state state)
|
||||
events (atom [])]
|
||||
(with-redefs [app.nitrate/call (fn [_cfg method _params]
|
||||
(case method
|
||||
:get-organization-sso {:active true}
|
||||
:get-organization-summary {:name "Organization"}))
|
||||
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
|
||||
app.auth.oidc/get-info (fn [& _]
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info))
|
||||
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
||||
(#'oidc/callback-handler cfg request)
|
||||
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
|
||||
(t/is (= {:organization-id test-organization-id
|
||||
:failure-reason "user-info-failed"}
|
||||
(:props (first @events)))))))
|
||||
|
||||
(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
||||
:organization-id test-organization-id})
|
||||
request (assoc-in (default-request cfg :state state) [:params :error] "access_denied")
|
||||
events (atom [])]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (.contains loc "error=unable-to-auth"))
|
||||
(t/is (.contains loc "hint=access_denied"))
|
||||
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
|
||||
(t/is (= {:organization-id test-organization-id
|
||||
:failure-reason "access-denied"}
|
||||
(:props (first @events)))))))))
|
||||
|
||||
(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check
|
||||
(t/testing "organization SSO provider must use SSRF protection"
|
||||
(let [captured-params (atom nil)]
|
||||
@@ -588,138 +531,3 @@
|
||||
:issuer "https://idp.example.com"})
|
||||
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
|
||||
"SSRF protection must be disabled for organization SSO")))))
|
||||
|
||||
(defn- ssl-handshake-failure
|
||||
[]
|
||||
(javax.net.ssl.SSLHandshakeException. "Remote host terminated the handshake"))
|
||||
|
||||
(t/deftest prepare-organization-sso-provider-raises-on-discovery-network-failure
|
||||
(t/testing "SSL/network failures during OIDC discovery become controlled validation errors"
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
|
||||
(let [e (try
|
||||
(#'oidc/prepare-organization-sso-provider
|
||||
{}
|
||||
{:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://wrong-idp.example.com"})
|
||||
(catch Throwable t t))]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest prepare-organization-sso-provider-raises-on-discovery-non-200
|
||||
(t/testing "non-200 OIDC discovery responses become controlled validation errors"
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 404 :body "not found"}}]
|
||||
(let [e (try
|
||||
(#'oidc/prepare-organization-sso-provider
|
||||
{}
|
||||
{:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"})
|
||||
(catch Throwable t t))
|
||||
data (ex-data e)]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :validation (:type data)))
|
||||
(t/is (= :invalid-sso-config (:code data)))
|
||||
(t/is (= 404 (:response-status-code data)))
|
||||
(t/is (= "unable to discover OIDC configuration" (ex-message e)))
|
||||
(t/is (str/includes? (str (:discover-uri data)) "openid-configuration"))))))
|
||||
|
||||
(t/deftest prepare-organization-sso-provider-raises-on-ssrf-blocked-issuer
|
||||
(t/testing "SSRF/DNS failures for the issuer URL become invalid-sso-config, not ssrf-blocked-target"
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:side-effect (fn [& _]
|
||||
(ex/raise :type :validation
|
||||
:code :ssrf-blocked-target
|
||||
:hint "uri host could not be resolved"))}]
|
||||
(let [e (try
|
||||
(#'oidc/prepare-organization-sso-provider
|
||||
{}
|
||||
{:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://unresolvable.invalid"})
|
||||
(catch Throwable t t))]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :invalid-sso-config (:code (ex-data e))))
|
||||
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
|
||||
|
||||
(t/deftest prepare-organization-sso-provider-raises-on-jwks-network-failure
|
||||
(t/testing "SSL/network failures while fetching JWKs become controlled validation errors"
|
||||
(let [discovery-body (str "{\"authorization_endpoint\":\"https://idp.example.com/auth\","
|
||||
"\"token_endpoint\":\"https://idp.example.com/token\","
|
||||
"\"userinfo_endpoint\":\"https://idp.example.com/userinfo\","
|
||||
"\"jwks_uri\":\"https://idp.example.com/jwks\"}")]
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:side-effect (fn [_cfg request & _]
|
||||
(if (str/includes? (str (:uri request)) "openid-configuration")
|
||||
{:status 200 :body discovery-body}
|
||||
(throw (ssl-handshake-failure))))}]
|
||||
(let [e (try
|
||||
(#'oidc/prepare-organization-sso-provider
|
||||
{}
|
||||
{:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"})
|
||||
(catch Throwable t t))]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :invalid-sso-config (:code (ex-data e)))))))))
|
||||
|
||||
(t/deftest populate-jwks-strict-wraps-non-invalid-sso-config-errors
|
||||
(t/testing "strict JWKS path wraps unrelated structured errors instead of rethrowing them"
|
||||
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
|
||||
:side-effect (fn [& _]
|
||||
(ex/raise :type :validation
|
||||
:code :ssrf-blocked-target
|
||||
:hint "uri host could not be resolved"))}]
|
||||
(let [e (try
|
||||
(#'oidc/populate-jwks
|
||||
{}
|
||||
{:id "oidc"
|
||||
:jwks-uri "https://idp.example.com/jwks"
|
||||
:strict-jwks? true})
|
||||
(catch Throwable t t))]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :invalid-sso-config (:code (ex-data e))))
|
||||
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
|
||||
|
||||
(t/deftest populate-jwks-strict-rethrows-invalid-sso-config
|
||||
(t/testing "strict JWKS path rethrows an already-controlled invalid-sso-config"
|
||||
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
|
||||
:side-effect (fn [& _]
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-sso-config
|
||||
:hint "unable to retrieve JWKs"
|
||||
:jwks-uri "https://idp.example.com/jwks"))}]
|
||||
(let [e (try
|
||||
(#'oidc/populate-jwks
|
||||
{}
|
||||
{:id "oidc"
|
||||
:jwks-uri "https://idp.example.com/jwks"
|
||||
:strict-jwks? true})
|
||||
(catch Throwable t t))]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :invalid-sso-config (:code (ex-data e))))
|
||||
(t/is (= "unable to retrieve JWKs" (ex-message e)))
|
||||
(t/is (= "https://idp.example.com/jwks" (:jwks-uri (ex-data e))))))))
|
||||
|
||||
(t/deftest build-organization-sso-auth-redirect-uri-raises-on-unreachable-provider
|
||||
(t/testing "check-nitrate-sso path surfaces a controlled error when the issuer is unreachable"
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
|
||||
(let [e (try
|
||||
(oidc/build-organization-sso-auth-redirect-uri
|
||||
{}
|
||||
{:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://wrong-idp.example.com"}
|
||||
:dest-url "https://localhost:3449/#/dashboard"
|
||||
:organization-id #uuid "00000000-0000-0000-0000-000000000001")
|
||||
(catch Throwable t t))]
|
||||
(t/is (ex/error? e))
|
||||
(t/is (= :validation (:type (ex-data e))))
|
||||
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
|
||||
@@ -23,7 +23,6 @@
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[backend-tests.helpers :as th]
|
||||
[backend-tests.storage-test :as stt]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
@@ -208,29 +207,6 @@
|
||||
(t/is (= (count result) 1))
|
||||
(t/is (every? uuid? result)))))
|
||||
|
||||
(t/deftest import-binfile-v3-persists-manifest-metadata
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (prepare-simple-file profile)
|
||||
output (tmp/tempfile :suffix ".zip")]
|
||||
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
(let [result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))
|
||||
imported (bfc/get-file th/*system* (first result))]
|
||||
|
||||
(t/is (= (count result) 1))
|
||||
(t/is (some? (get-in imported [:metadata :generated-by])))
|
||||
(t/is (= "penpot" (get-in imported [:metadata :referer]))))))
|
||||
|
||||
(t/deftest read-obj-rejects-oversized-buffer
|
||||
;; N1-07: read-obj! must reject objects exceeding max-object-size
|
||||
;; before attempting to allocate the buffer
|
||||
@@ -253,88 +229,3 @@
|
||||
;; With the guard, it raises :validation :max-file-size-reached.
|
||||
(t/is (= :validation (:type out)))
|
||||
(t/is (= :max-file-size-reached (:code out))))))))
|
||||
|
||||
(t/deftest import-rejects-too-many-zip-entries
|
||||
;; import must reject ZIP files exceeding max-zip-entries
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (prepare-simple-file profile)
|
||||
output (tmp/tempfile :suffix ".zip")]
|
||||
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Import with max-zip-entries=1 — the exported ZIP has more entries
|
||||
(let [cfg (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(assoc ::bfc/import-max-zip-entries 1))
|
||||
out (try
|
||||
(v3/import-files! cfg)
|
||||
:no-error
|
||||
(catch Throwable e
|
||||
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
|
||||
d)))]
|
||||
(t/is (= :validation (:type out)))
|
||||
(t/is (= :too-many-zip-entries (:code out))))))
|
||||
|
||||
(defn- prepare-file-with-media
|
||||
"Creates a file with a media object backed by a real storage object,
|
||||
so that v3 export produces objects/ entries."
|
||||
[profile]
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(stt/configure-storage-backend))
|
||||
|
||||
sobject (sto/put-object! storage {::sto/content (sto/content "media-bytes")
|
||||
:content-type "image/svg+xml"
|
||||
:bucket "file-media-object"})
|
||||
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
|
||||
mobj (th/create-file-media-object* {:file-id (:id file)
|
||||
:is-local true
|
||||
:media-id (:id sobject)})]
|
||||
(update-file!
|
||||
:file-id (:id file)
|
||||
:profile-id (:id profile)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes
|
||||
[{:type :add-media
|
||||
:object mobj}])
|
||||
|
||||
(dissoc file :data)))
|
||||
|
||||
(t/deftest import-rejects-oversized-object
|
||||
;; import must reject storage objects exceeding max-object-size
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (prepare-file-with-media profile)
|
||||
output (tmp/tempfile :suffix ".zip")]
|
||||
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Import with max-object-size=1 — the media object will exceed this
|
||||
(let [cfg (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(assoc ::bfc/import-max-object-size 1))
|
||||
out (try
|
||||
(v3/import-files! cfg)
|
||||
:no-error
|
||||
(catch Throwable e
|
||||
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
|
||||
d)))]
|
||||
(t/is (= :validation (:type out)))
|
||||
(t/is (= :max-file-size-reached (:code out))))))
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
(ns backend-tests.db-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t])
|
||||
@@ -42,13 +41,3 @@
|
||||
|
||||
(t/testing "maximum pool size is reasonable"
|
||||
(t/is (pos? (:maximum-pool-size stats))))))
|
||||
|
||||
(t/deftest uuid->hash-code-is-deterministic
|
||||
(t/is (= (db/uuid->hash-code uuid/zero)
|
||||
(db/uuid->hash-code uuid/zero))))
|
||||
|
||||
(t/deftest uuid->hash-code-returns-long
|
||||
(t/is (instance? Long (db/uuid->hash-code uuid/zero))))
|
||||
|
||||
(t/deftest uuid->hash-code-stable-for-zero-uuid
|
||||
(t/is (= 3659997967308761462 (db/uuid->hash-code uuid/zero))))
|
||||
@@ -1,47 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.demo-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.tasks.demo-purge :as demo-purge]
|
||||
[app.worker :as wrk]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest demo-profile-created-without-deleted-at
|
||||
(let [profile (th/create-profile* 999 {:is-demo true})]
|
||||
(t/is (true? (:is-demo profile)))
|
||||
(t/is (nil? (:deleted-at profile)))
|
||||
(t/is (some? (:id profile)))))
|
||||
|
||||
(t/deftest get-profile-finds-demo-user-without-override
|
||||
(let [profile (th/create-profile* 998 {:is-demo true})
|
||||
found (db/run! th/*pool*
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(profile/get-profile conn (:id profile))))]
|
||||
(t/is (some? found))
|
||||
(t/is (= (:id profile) (:id found)))))
|
||||
|
||||
(t/deftest demo-purge-handler-submits-delete-object
|
||||
(let [profile (th/create-profile* 996 {:is-demo true})
|
||||
handler (ig/init-key :app.tasks.demo-purge/handler
|
||||
{::db/pool th/*pool*})
|
||||
submitted (atom nil)]
|
||||
(with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}]
|
||||
(reset! submitted {:task task :params params}))]
|
||||
(handler {:props {:profile-id (:id profile)
|
||||
:deleted-at (ct/now)}}))
|
||||
(t/is (= :delete-object (:task @submitted)))
|
||||
(t/is (= :profile (:object (:params @submitted))))
|
||||
(t/is (= (:id profile) (:id (:params @submitted))))
|
||||
(t/is (some? (:deleted-at (:params @submitted))))))
|
||||
@@ -13,7 +13,6 @@
|
||||
[app.http.access-token :as actoken]
|
||||
[app.http.assets :as assets]
|
||||
[app.http.session :as session]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.access-token :as access-token]
|
||||
[app.storage :as sto]
|
||||
[backend-tests.helpers :as th]
|
||||
@@ -37,16 +36,11 @@
|
||||
(assoc storage ::sto/backend :fs))
|
||||
|
||||
(defn- create-storage-object!
|
||||
"Create a storage object with the given bucket and content.
|
||||
Optional opts map can include :profile-id to set the owner."
|
||||
([storage bucket content]
|
||||
(create-storage-object! storage bucket content {}))
|
||||
([storage bucket content {:keys [profile-id]}]
|
||||
(sto/put-object! storage (cond-> {::sto/content (sto/content content)
|
||||
:bucket bucket
|
||||
:content-type "text/plain"}
|
||||
(some? profile-id)
|
||||
(assoc :profile-id profile-id)))))
|
||||
"Create a storage object with the given bucket and content."
|
||||
[storage bucket content]
|
||||
(sto/put-object! storage {::sto/content (sto/content content)
|
||||
:bucket bucket
|
||||
:content-type "text/plain"}))
|
||||
|
||||
(defn- make-handler-cfg
|
||||
"Build a minimal cfg map for the assets handlers."
|
||||
@@ -594,111 +588,6 @@
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Tests: file-objects-handler — share-link authz (issue #11338)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest file-objects-handler-anonymous-with-valid-share-id-succeeds
|
||||
;; Anonymous request with a valid share-id matching the file must
|
||||
;; succeed (share-link viewers are unauthenticated by definition).
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
slink (:result (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"}))
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
:query-params {:share-id (str (:id slink))}}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-objects-handler-anonymous-with-share-id-for-other-file-returns-404
|
||||
;; A share-id from file A must not grant access to assets of file B.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
file-a (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
file-b (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
media-a (create-storage-object! storage "file-media-object" "image A")
|
||||
media-obj-a (th/create-file-media-object* {:file-id (:id file-a)
|
||||
:media-id (:id media-a)})
|
||||
media-b (create-storage-object! storage "file-media-object" "image B")
|
||||
media-obj-b (th/create-file-media-object* {:file-id (:id file-b)
|
||||
:media-id (:id media-b)})
|
||||
slink (:result (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file-a)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"}))
|
||||
request {:path-params {:id (str (:id media-obj-b))}
|
||||
:query-params {:share-id (str (:id slink))}}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-objects-handler-anonymous-with-malformed-share-id-returns-404
|
||||
;; Malformed share-id must not raise; it must short-circuit to 404.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
media-storage (create-storage-object! storage "file-media-object" "image data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id media-storage)})
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
:query-params {:share-id "not-a-uuid"}}
|
||||
response (assets/file-objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest file-thumbnails-handler-anonymous-with-valid-share-id-succeeds
|
||||
;; Thumbnail endpoint must also honor the share-id query param.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
|
||||
media-obj (th/create-file-media-object* {:file-id (:id file)
|
||||
:media-id (:id thumb-storage)})
|
||||
slink (:result (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"}))
|
||||
request {:path-params {:id (str (:id media-obj))}
|
||||
:query-params {:share-id (str (:id slink))}}
|
||||
response (assets/file-thumbnails-handler cfg request)]
|
||||
;; Falls back to media-id since no thumbnail-id, but still serves
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-expired-object
|
||||
;; Expired objects should return 404 (get-object filters them out).
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
@@ -713,70 +602,3 @@
|
||||
::session/profile-id (:id profile)}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Tests: objects-handler — tempfile bucket ownership (T9-F-10)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest objects-handler-tempfile-owner-can-access
|
||||
;; Owner of a tempfile should be able to access it via session auth.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
|
||||
request {:path-params {:id (str (:id object))}
|
||||
::session/profile-id (:id owner)}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-tempfile-non-owner-gets-404
|
||||
;; Non-owner accessing a tempfile should get 404 (not 403, to avoid leaking existence).
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
stranger (th/create-profile* 2)
|
||||
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
|
||||
request {:path-params {:id (str (:id object))}
|
||||
::session/profile-id (:id stranger)}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-tempfile-access-token-owner-can-access
|
||||
;; Owner of a tempfile should be able to access it via access token auth.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
|
||||
request {:path-params {:id (str (:id object))}
|
||||
::actoken/profile-id (:id owner)}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-tempfile-access-token-non-owner-gets-404
|
||||
;; Non-owner accessing a tempfile via access token should get 404.
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
owner (th/create-profile* 1)
|
||||
stranger (th/create-profile* 2)
|
||||
object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)})
|
||||
request {:path-params {:id (str (:id object))}
|
||||
::actoken/profile-id (:id stranger)}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest objects-handler-tempfile-no-stored-profile-id-serves
|
||||
;; Legacy tempfile objects without stored profile-id should be accessible
|
||||
;; to any authenticated user (backward compatibility).
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
cfg (make-handler-cfg storage)
|
||||
stranger (th/create-profile* 1)
|
||||
object (create-storage-object! storage "tempfile" "legacy temp data")
|
||||
request {:path-params {:id (str (:id object))}
|
||||
::session/profile-id (:id stranger)}
|
||||
response (assets/objects-handler cfg request)]
|
||||
(t/is (= 204 (::yres/status response)))))
|
||||
@@ -6,12 +6,10 @@
|
||||
|
||||
(ns backend-tests.http-middleware-test
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token]
|
||||
[app.http.errors :as http-errors]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.session :as session]
|
||||
[app.main :as-alias main]
|
||||
@@ -302,7 +300,7 @@
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (= "invalid JSON in request body" (-> ex ex-data :hint)))))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-request-too-big-exception
|
||||
;; When RequestTooBigException is raised (e.g. the request body
|
||||
@@ -321,7 +319,7 @@
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :request-body-too-large (-> ex ex-data :code)))
|
||||
(t/is (= "request body exceeds size limit" (-> ex ex-data :hint)))))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-eof-exception
|
||||
;; When java.io.EOFException is raised (e.g. the body stream
|
||||
@@ -339,7 +337,7 @@
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (= "unexpected end of request body" (-> ex ex-data :hint)))))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-runtime-exception-with-cause
|
||||
;; When a RuntimeException with a non-nil ex-cause is raised,
|
||||
@@ -379,7 +377,7 @@
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :unexpected (:code body)))
|
||||
(t/is (nil? (:hint body)))))
|
||||
(t/is (= "boom" (:hint body)))))
|
||||
|
||||
(t/deftest parse-request-non-runtime-throwable
|
||||
;; When a non-RuntimeException Throwable is raised (e.g. an
|
||||
@@ -399,45 +397,4 @@
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :io-exception (:code body)))
|
||||
(t/is (nil? (:hint body)))))
|
||||
|
||||
(t/deftest internal-error-strips-sensitive-fields
|
||||
;; When an :internal error is raised with :state, :path, and
|
||||
;; :context, those fields must not appear in the response body.
|
||||
;; :hint is part of the error protocol and is preserved.
|
||||
(let [cause (ex-info "internal error"
|
||||
{:type :internal
|
||||
:code :test-error
|
||||
:hint "safe user-facing hint"
|
||||
:state "XX000"
|
||||
:path "/data/penpot/storage"
|
||||
:context {:backend :s3 :bucket "prod"}})
|
||||
response (http-errors/handle cause {})
|
||||
body (::yres/body response)]
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :test-error (:code body)))
|
||||
(t/is (= "safe user-facing hint" (:hint body)))
|
||||
(t/is (nil? (:state body)))
|
||||
(t/is (nil? (:path body)))
|
||||
(t/is (nil? (:context body)))))
|
||||
|
||||
(t/deftest unhandled-exinfo-strips-sensitive-fields
|
||||
;; When an ex-info with an unregistered :type (dispatches through
|
||||
;; handle-exception :default :else) carries :state and :path,
|
||||
;; those fields must not appear in the response body.
|
||||
;; :hint is part of the error protocol and is preserved.
|
||||
(let [cause (ex-info "something broke"
|
||||
{:type :unregistered-type
|
||||
:code :custom-code
|
||||
:hint "safe user-facing hint"
|
||||
:state "internal-state"
|
||||
:path "/internal/path"})
|
||||
response (http-errors/handle cause {})
|
||||
body (::yres/body response)]
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :custom-code (:code body)))
|
||||
(t/is (= "safe user-facing hint" (:hint body)))
|
||||
(t/is (nil? (:state body)))
|
||||
(t/is (nil? (:path body)))))
|
||||
(t/is (= "network gone" (:hint body)))))
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
(t/deftest info-service-uri-not-configured
|
||||
(t/testing "info throws when service URI is not configured"
|
||||
(with-redefs [cf/get (constantly nil)]
|
||||
(with-redefs [cf/get (th/config-get-mock {})]
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
err (ex/try! (media.remote/process (mk-system)
|
||||
{:cmd :info :input {:path path :mtype "image/jpeg"}}))]
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.passwords-test
|
||||
(:require
|
||||
[app.auth.passwords :as passwords]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(defn- run-validation
|
||||
[password]
|
||||
(try
|
||||
(passwords/validate-password password)
|
||||
nil
|
||||
(catch Throwable e
|
||||
e)))
|
||||
|
||||
(t/deftest validate-password-accepts-strong-password
|
||||
(t/is (nil? (run-validation "Str0ng!Pass"))))
|
||||
|
||||
(t/deftest validate-password-rejects-too-short-password
|
||||
(let [error (run-validation "Ab1!x")]
|
||||
(t/is (th/ex-of-code? error :weak-password))
|
||||
(t/is (= ["errors.weak-password.too-short"] (:details (ex-data error))))))
|
||||
|
||||
(t/deftest validate-password-rejects-missing-lowercase
|
||||
(let [error (run-validation "ABCDEFG1!")]
|
||||
(t/is (th/ex-of-code? error :weak-password))
|
||||
(t/is (= ["errors.weak-password.insufficient-lowercase"]
|
||||
(:details (ex-data error))))))
|
||||
|
||||
(t/deftest validate-password-rejects-missing-uppercase
|
||||
(let [error (run-validation "abcdefg1!")]
|
||||
(t/is (th/ex-of-code? error :weak-password))
|
||||
(t/is (= ["errors.weak-password.insufficient-uppercase"]
|
||||
(:details (ex-data error))))))
|
||||
|
||||
(t/deftest validate-password-rejects-missing-digit
|
||||
(let [error (run-validation "Abcdefgh!")]
|
||||
(t/is (th/ex-of-code? error :weak-password))
|
||||
(t/is (= ["errors.weak-password.insufficient-digits"]
|
||||
(:details (ex-data error))))))
|
||||
|
||||
(t/deftest validate-password-rejects-missing-special
|
||||
(let [error (run-validation "Abcdefgh1")]
|
||||
(t/is (th/ex-of-code? error :weak-password))
|
||||
(t/is (= ["errors.weak-password.insufficient-special"]
|
||||
(:details (ex-data error))))))
|
||||
@@ -1,106 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-auth-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.http.session :as session]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[yetti.response :as yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest logout-invalidates-current-session
|
||||
(let [prof (th/create-profile* 1)
|
||||
manager (::session/manager th/*system*)
|
||||
sid (uuid/random)
|
||||
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
|
||||
sid (:id prof) "test-agent"])
|
||||
session (session/read-session manager sid)]
|
||||
|
||||
;; Arrange: session exists before logout
|
||||
(t/is (some? session) "session should exist before logout")
|
||||
(t/is (= sid (:id session)))
|
||||
|
||||
;; Act: simulate Ring request as produced by wrap-authz (has ::session/session)
|
||||
;; delete-fn is used as response transform via rph/with-transform in auth/logout
|
||||
(let [request {::session/session session}
|
||||
response {}
|
||||
delete-fn (session/delete-fn th/*system*)
|
||||
result (delete-fn request response)]
|
||||
|
||||
;; Assert: server-side session is deleted (CWE-613)
|
||||
(t/is (nil? (session/read-session manager sid))
|
||||
"session must be deleted server-side after logout (GHSA-mj9f-5cwq-7p3q)")
|
||||
|
||||
;; Assert: cookie is cleared
|
||||
(t/is (= "" (get-in result [::yres/cookies "auth-token" :value]))
|
||||
"auth-token cookie should be cleared")
|
||||
(t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age]))
|
||||
"auth-token cookie max-age should be 0"))))
|
||||
|
||||
(t/deftest logout-clears-cookie-even-when-session-missing
|
||||
(let [manager (::session/manager th/*system*)
|
||||
sid (uuid/random)
|
||||
;; No session inserted, read should be nil
|
||||
_ (t/is (nil? (session/read-session manager sid)))
|
||||
request {}
|
||||
response {}
|
||||
delete-fn (session/delete-fn th/*system*)
|
||||
result (delete-fn request response)]
|
||||
|
||||
;; Should still clear cookie (idempotent)
|
||||
(t/is (= "" (get-in result [::yres/cookies "auth-token" :value])))
|
||||
(t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age])))))
|
||||
|
||||
(t/deftest logout-does-not-invalidate-other-sessions
|
||||
(let [prof (th/create-profile* 1)
|
||||
manager (::session/manager th/*system*)
|
||||
sid1 (uuid/random)
|
||||
sid2 (uuid/random)
|
||||
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
|
||||
sid1 (:id prof) "agent-1"])
|
||||
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
|
||||
sid2 (:id prof) "agent-2"])
|
||||
s1 (session/read-session manager sid1)
|
||||
s2 (session/read-session manager sid2)]
|
||||
|
||||
(t/is (some? s1))
|
||||
(t/is (some? s2))
|
||||
|
||||
;; Logout only sid1
|
||||
(let [request {::session/session s1}
|
||||
response {}
|
||||
delete-fn (session/delete-fn th/*system*)]
|
||||
(delete-fn request response))
|
||||
|
||||
;; sid1 deleted, sid2 intact
|
||||
(t/is (nil? (session/read-session manager sid1)) "current session should be deleted")
|
||||
(t/is (some? (session/read-session manager sid2)) "other sessions should remain")))
|
||||
|
||||
(t/deftest replay-after-logout-cannot-authenticate
|
||||
(let [prof (th/create-profile* 1)
|
||||
manager (::session/manager th/*system*)
|
||||
sid (uuid/random)
|
||||
_ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
|
||||
sid (:id prof) "test-agent"])
|
||||
session (session/read-session manager sid)]
|
||||
|
||||
(t/is (some? session) "session exists before logout")
|
||||
|
||||
;; Simulate logout
|
||||
(let [request {::session/session session}
|
||||
response {}
|
||||
delete-fn (session/delete-fn th/*system*)]
|
||||
(delete-fn request response))
|
||||
|
||||
;; Replay: attempt to read session with same sid should fail (no profile attached)
|
||||
(t/is (nil? (session/read-session manager sid))
|
||||
"replayed token must not resolve to a valid session after logout")))
|
||||
|
||||
|
||||
@@ -11,55 +11,32 @@
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.binfile :as binfile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest import-binfile-schema-omits-file-id
|
||||
(t/deftest import-binfile-schema-rejects-file-id
|
||||
;; N1-06: file-id parameter must be removed from schema for security
|
||||
;; The schema should not accept file-id as a valid parameter
|
||||
(let [schema @#'binfile/schema:import-binfile
|
||||
validator (sm/lazy-validator schema)
|
||||
|
||||
;; Valid params without file-id
|
||||
valid-params {:name "test"
|
||||
:project-id (uuid/random)
|
||||
:version 3
|
||||
:upload-id (uuid/random)}]
|
||||
:upload-id (uuid/random)}
|
||||
|
||||
;; Params with file-id (should be rejected after fix)
|
||||
params-with-file-id (assoc valid-params :file-id (uuid/random))]
|
||||
|
||||
;; Valid params without file-id should pass
|
||||
(t/is (true? (validator valid-params))
|
||||
"params without file-id should be valid")
|
||||
|
||||
(t/is (not (contains? (sm/keys (second schema)) :file-id))
|
||||
"file-id should not be a declared parameter")))
|
||||
|
||||
(t/deftest import-binfile-schema-rejects-unsupported-version
|
||||
;; T1-N2-03: version parameter should be restricted to supported values (1 or 3)
|
||||
(let [schema @#'binfile/schema:import-binfile
|
||||
validator (sm/lazy-validator schema)
|
||||
base-params {:name "test"
|
||||
:project-id (uuid/random)
|
||||
:upload-id (uuid/random)}]
|
||||
|
||||
;; Version 1 should be accepted
|
||||
(t/is (true? (validator (assoc base-params :version 1)))
|
||||
"version 1 should be valid")
|
||||
|
||||
;; Version 3 should be accepted
|
||||
(t/is (true? (validator (assoc base-params :version 3)))
|
||||
"version 3 should be valid")
|
||||
|
||||
;; Version 2 should be rejected
|
||||
(t/is (false? (validator (assoc base-params :version 2)))
|
||||
"version 2 should be rejected")
|
||||
|
||||
;; Version 0 should be rejected
|
||||
(t/is (false? (validator (assoc base-params :version 0)))
|
||||
"version 0 should be rejected")
|
||||
|
||||
;; Negative version should be rejected
|
||||
(t/is (false? (validator (assoc base-params :version -1)))
|
||||
"negative version should be rejected")
|
||||
|
||||
;; Version 4 should be rejected
|
||||
(t/is (false? (validator (assoc base-params :version 4)))
|
||||
"version 4 should be rejected")))
|
||||
;; Params with file-id should fail validation after fix
|
||||
;; (Currently this will fail because file-id is still in schema)
|
||||
(t/is (false? (validator params-with-file-id))
|
||||
"params with file-id should be rejected")))
|
||||
@@ -285,196 +285,3 @@
|
||||
|
||||
(let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
|
||||
(t/is (= 0 (count threads)))))))))
|
||||
|
||||
(t/deftest share-link-who-comment-team-cannot-comment
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
outsider (th/create-profile* 2 {:is-active true})
|
||||
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:team-id (:id team)
|
||||
:profile-id (:id owner)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
page-id (get-in file [:data :pages 0])
|
||||
|
||||
share (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{page-id}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
share-id (get-in share [:result :id])]
|
||||
|
||||
(t/testing "outsider with who-comment=team share-link cannot get-comment-threads"
|
||||
(let [out (th/command! {::th/type :get-comment-threads
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:share-id share-id})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))))
|
||||
|
||||
(t/testing "outsider with who-comment=team share-link cannot create-comment-thread"
|
||||
(let [out (th/command! {::th/type :create-comment-thread
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:page-id page-id
|
||||
:position (gpt/point 0)
|
||||
:content "outsider comment"
|
||||
:frame-id uuid/zero
|
||||
:share-id share-id})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))))))
|
||||
|
||||
(t/deftest share-link-who-comment-all-can-comment
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
outsider (th/create-profile* 2 {:is-active true})
|
||||
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:team-id (:id team)
|
||||
:profile-id (:id owner)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
page-id (get-in file [:data :pages 0])
|
||||
|
||||
share (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{page-id}
|
||||
:who-comment "all"
|
||||
:who-inspect "all"})
|
||||
share-id (get-in share [:result :id])]
|
||||
|
||||
(t/testing "outsider with who-comment=all share-link can get-comment-threads"
|
||||
(let [out (th/command! {::th/type :get-comment-threads
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:share-id share-id})]
|
||||
(t/is (th/success? out))))
|
||||
|
||||
(t/testing "outsider with who-comment=all share-link can create-comment-thread"
|
||||
(let [out (th/command! {::th/type :create-comment-thread
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:page-id page-id
|
||||
:position (gpt/point 0)
|
||||
:content "outsider comment"
|
||||
:frame-id uuid/zero
|
||||
:share-id share-id})]
|
||||
(t/is (th/success? out))))))
|
||||
|
||||
(t/deftest share-link-page-scope-enforced
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
outsider (th/create-profile* 2 {:is-active true})
|
||||
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
project (th/create-project* 1 {:team-id (:id team)
|
||||
:profile-id (:id owner)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
|
||||
page-a (get-in file [:data :pages 0])
|
||||
page-b (uuid/random)
|
||||
|
||||
_ (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id owner)
|
||||
:id (:id file)
|
||||
:session-id (uuid/random)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:changes [{:type :add-page
|
||||
:id page-b
|
||||
:page {:id page-b
|
||||
:name "Page B"
|
||||
:options {}
|
||||
:objects {}}}]})
|
||||
|
||||
thread-a (th/command! {::th/type :create-comment-thread
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:page-id page-a
|
||||
:position (gpt/point 0)
|
||||
:content "comment on page A"
|
||||
:frame-id uuid/zero})
|
||||
thread-b (th/command! {::th/type :create-comment-thread
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:page-id page-b
|
||||
:position (gpt/point 0)
|
||||
:content "comment on page B"
|
||||
:frame-id uuid/zero})
|
||||
|
||||
thread-a-id (get-in thread-a [:result :id])
|
||||
thread-b-id (get-in thread-b [:result :id])
|
||||
|
||||
share (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{page-a}
|
||||
:who-comment "all"
|
||||
:who-inspect "all"})
|
||||
share-id (get-in share [:result :id])]
|
||||
|
||||
(t/testing "share-link holder can get-comment-threads for shared page only"
|
||||
(let [out (th/command! {::th/type :get-comment-threads
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:share-id share-id})
|
||||
result (:result out)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count result)))
|
||||
(t/is (= page-a (:page-id (first result))))))
|
||||
|
||||
(t/testing "share-link holder cannot get-comment-thread for unshared page"
|
||||
(let [out (th/command! {::th/type :get-comment-thread
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:id thread-b-id
|
||||
:share-id share-id})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))))
|
||||
|
||||
(t/testing "share-link holder can get-comment-thread for shared page"
|
||||
(let [out (th/command! {::th/type :get-comment-thread
|
||||
::rpc/profile-id (:id outsider)
|
||||
:file-id (:id file)
|
||||
:id thread-a-id
|
||||
:share-id share-id})]
|
||||
(t/is (th/success? out))))
|
||||
|
||||
(t/testing "share-link holder cannot get-comments for thread on unshared page"
|
||||
(let [out (th/command! {::th/type :get-comments
|
||||
::rpc/profile-id (:id outsider)
|
||||
:thread-id thread-b-id
|
||||
:share-id share-id})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))))))
|
||||
|
||||
(t/deftest membership-can-still-comment
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
member (th/create-profile* 2 {:is-active true})
|
||||
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
_ (th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id member)
|
||||
:role :editor})
|
||||
project (th/create-project* 1 {:team-id (:id team)
|
||||
:profile-id (:id owner)})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:id project)})
|
||||
page-id (get-in file [:data :pages 0])]
|
||||
|
||||
(t/testing "team member can get-comment-threads without share-id"
|
||||
(let [out (th/command! {::th/type :get-comment-threads
|
||||
::rpc/profile-id (:id member)
|
||||
:file-id (:id file)})]
|
||||
(t/is (th/success? out))))
|
||||
|
||||
(t/testing "team member can create-comment-thread without share-id"
|
||||
(let [out (th/command! {::th/type :create-comment-thread
|
||||
::rpc/profile-id (:id member)
|
||||
:file-id (:id file)
|
||||
:page-id page-id
|
||||
:position (gpt/point 0)
|
||||
:content "member comment"
|
||||
:frame-id uuid/zero})]
|
||||
(t/is (th/success? out))))))
|
||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user