mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
133
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
113923ca17 | ||
|
|
4c9aeab65e | ||
|
|
c01306734a | ||
|
|
10e40e62a2 | ||
|
|
f06d1df5be | ||
|
|
eb0c51ed74 | ||
|
|
18ebf5a92d | ||
|
|
cdfdbf9adc | ||
|
|
1baa8bb29f | ||
|
|
11224940ab | ||
|
|
43bbf756b0 | ||
|
|
6bedd5ac76 | ||
|
|
c9a2b8f12f | ||
|
|
582187dd03 | ||
|
|
b4dc8207ff | ||
|
|
1d2c37e52c | ||
|
|
f7fd3e1cd5 | ||
|
|
06491a2d24 | ||
|
|
f7bdc9786c | ||
|
|
88a52d1098 | ||
|
|
2bce69f927 | ||
|
|
6e173a02fb | ||
|
|
4be749d45f | ||
|
|
e1a2d0b932 | ||
|
|
33e39bc7ed | ||
|
|
33f13f9bfd | ||
|
|
4adfa5d2f2 | ||
|
|
7079d33ae1 | ||
|
|
b33213787e | ||
|
|
5e1ced03ea | ||
|
|
44dfc04300 | ||
|
|
85a68ea3b2 | ||
|
|
b79680eeb7 | ||
|
|
c31236947c | ||
|
|
52573be074 | ||
|
|
316b58bc75 | ||
|
|
e93c419785 | ||
|
|
7732c93541 | ||
|
|
47d599fe34 | ||
|
|
0cacf9bd99 | ||
|
|
7c85837290 | ||
|
|
dd4a163217 | ||
|
|
9fa07e7468 | ||
|
|
77971740e6 | ||
|
|
2318866f8d | ||
|
|
9f6878d118 | ||
|
|
209aea8365 | ||
|
|
c200a4d777 | ||
|
|
a91c796b0e | ||
|
|
c378ec9218 | ||
|
|
4da6499197 | ||
|
|
aa3bc1ae98 | ||
|
|
fda6d56139 | ||
|
|
5080a90f76 | ||
|
|
4d90fe9126 | ||
|
|
ddc98bdd47 | ||
|
|
d826c7ac13 | ||
|
|
4339d8d244 | ||
|
|
df664fe96b | ||
|
|
7061ecae0a | ||
|
|
4ac14cfd08 | ||
|
|
1671cc4fcc | ||
|
|
e72c1869eb | ||
|
|
3be07ccced | ||
|
|
73c0668877 | ||
|
|
367e4d534c | ||
|
|
3f09f161ae | ||
|
|
5b4a5776cb | ||
|
|
d745dc4a3c | ||
|
|
c72bb331ef | ||
|
|
9311737f66 | ||
|
|
7ac61e0597 | ||
|
|
8acb92b782 | ||
|
|
c797656d17 | ||
|
|
f96d850049 | ||
|
|
ed04d509ed | ||
|
|
57c9c3f6a4 | ||
|
|
29dbf9ab12 | ||
|
|
509f5395cb | ||
|
|
4ecd8ffb89 | ||
|
|
0797d7235a | ||
|
|
5efd9cc3c5 | ||
|
|
e219ce20eb | ||
|
|
9e97477a98 | ||
|
|
c688cba8d8 | ||
|
|
68e1db984d | ||
|
|
3033da4409 | ||
|
|
aecfee0f02 | ||
|
|
a3bc4b0e3a | ||
|
|
350dc14632 | ||
|
|
c7f036bed0 | ||
|
|
3db7548c19 | ||
|
|
9528400c6e | ||
|
|
f7fc869e52 | ||
|
|
ef26231b8f | ||
|
|
93f02ea0b4 | ||
|
|
6d49fb2be0 | ||
|
|
fee416d275 | ||
|
|
986ee60cad | ||
|
|
e5c80edbf3 | ||
|
|
732162e720 | ||
|
|
985d219810 | ||
|
|
53985dc630 | ||
|
|
02c31e7348 | ||
|
|
69ef7e86cd | ||
|
|
d7daefafe2 | ||
|
|
c4d1a1bc94 | ||
|
|
1e6d438257 | ||
|
|
d4294bbf1e | ||
|
|
83efa28b12 | ||
|
|
16e52b0494 | ||
|
|
0fd2a9d26f | ||
|
|
5d2cb22966 | ||
|
|
900a7ef498 | ||
|
|
86c563f11f | ||
|
|
d63d6370c0 | ||
|
|
b9c92496f1 | ||
|
|
e01b36b841 | ||
|
|
2f04fcddbf | ||
|
|
e2d429d283 | ||
|
|
bf9825fcfe | ||
|
|
a131e40a6d | ||
|
|
5571c53502 | ||
|
|
6951876c13 | ||
|
|
399b00b86d | ||
|
|
bc9319eac5 | ||
|
|
5359ff04cf | ||
|
|
9875db2f82 | ||
|
|
1548748aed | ||
|
|
0702363b5c | ||
|
|
688c69b478 | ||
|
|
229d24e8f2 | ||
|
|
fdf1684565 |
No files matched your search
@@ -0,0 +1,58 @@
|
||||
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,9 +80,40 @@ 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: "MCP CI"
|
||||
name: "CI: MCP"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -24,6 +24,7 @@ opencode.json
|
||||
!AGENTS.md
|
||||
!CODE_OF_CONDUCT.md
|
||||
!SECURITY.md
|
||||
!HIGHLIGHTS.md
|
||||
/*.png
|
||||
/*.svg
|
||||
/*.sql
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,34 +1,16 @@
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
Act as a senior software engineer and perform a thorough 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.
|
||||
1. **Determine what is being reviewed** from the provided context:
|
||||
- **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill.
|
||||
- **If it is code** (diff, PR, code change) → load the **`code-review`** skill.
|
||||
|
||||
2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing.
|
||||
|
||||
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?
|
||||
|
||||
4. Follow the loaded skill's process and produce its output format.
|
||||
|
||||
## Strong Rules
|
||||
|
||||
@@ -36,44 +18,8 @@ Act as a senior software engineer and perform a thorough code review.
|
||||
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.
|
||||
5. Missing tests are an issue, not a suggestion. Report as a severity-tagged finding — never as a recommendation.
|
||||
|
||||
## 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]
|
||||
```
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: code-review-and-quality
|
||||
name: code-review
|
||||
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.
|
||||
---
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
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`
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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."
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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,7 +34,8 @@ 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/core` — JVM clojure.test conventions
|
||||
- `mem:backend/testing` — JVM clojure.test conventions
|
||||
- `mem:exporter/testing` — exporter unit tests
|
||||
|
||||
## Key Rules
|
||||
|
||||
|
||||
@@ -212,6 +212,37 @@ 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
|
||||
@@ -400,6 +431,8 @@ 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
|
||||
|
||||
@@ -410,8 +443,12 @@ 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
|
||||
@@ -732,6 +769,14 @@ 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
|
||||
|
||||
@@ -5,7 +5,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
|
||||
## Focused memories
|
||||
|
||||
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
|
||||
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-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`.
|
||||
- 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`.
|
||||
|
||||
@@ -101,10 +102,5 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`.
|
||||
|
||||
## 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`.
|
||||
|
||||
Backend test commands, coverage rules, and conventions: `mem:backend/testing`.
|
||||
Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
||||
@@ -14,10 +14,7 @@
|
||||
|
||||
## Storage and media
|
||||
|
||||
- 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.
|
||||
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
|
||||
- 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.
|
||||
@@ -28,4 +25,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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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`.
|
||||
@@ -5,9 +5,10 @@
|
||||
## 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`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
@@ -31,4 +32,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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
|
||||
- **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`.
|
||||
- **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, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
|
||||
- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`.
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
|
||||
@@ -72,6 +73,30 @@ 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
|
||||
- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
|
||||
- `mem:backend/testing` — JVM `clojure.test` under `backend/test/`
|
||||
|
||||
## When to Use
|
||||
|
||||
|
||||
@@ -34,6 +34,28 @@ 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
|
||||
|
||||
Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100.
|
||||
|
||||
---
|
||||
|
||||
# Memory system
|
||||
|
||||
Memories are the **primary project guidance** — not docs or readme files.
|
||||
@@ -113,4 +135,5 @@ 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`.
|
||||
|
||||
+26
-4
@@ -23,17 +23,39 @@
|
||||
- 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))
|
||||
|
||||
|
||||
## 2.17.1 (Unreleased)
|
||||
## 2.17.2
|
||||
|
||||
### :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 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 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 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 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
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
file named “{{file-name|abbreviate:25}}”.
|
||||
</p>
|
||||
<p>
|
||||
Since this file is in your Penpot team, you can provide access by sending a view-only link.
|
||||
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.
|
||||
</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 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.
|
||||
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.
|
||||
|
||||
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 Your Penpot 's team, so direct access cannot be
|
||||
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:
|
||||
</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 Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access:
|
||||
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:
|
||||
|
||||
- Move the File to Another Team:
|
||||
|
||||
|
||||
@@ -190,6 +190,20 @@ 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>
|
||||
@@ -222,6 +236,7 @@ Debug Main Page
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Import binfile:</legend>
|
||||
<desc>Import penpot file in binary format.</desc>
|
||||
@@ -236,6 +251,34 @@ 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,4 +45,10 @@
|
||||
{:permits 4}
|
||||
|
||||
:send-user-feedback/by-profile
|
||||
{:permits 1 :queue 3}}
|
||||
{:permits 1 :queue 3}
|
||||
|
||||
:import-binfile/global
|
||||
{:permits 4}
|
||||
|
||||
:import-binfile/by-profile
|
||||
{:permits 1 :queue 2}}
|
||||
@@ -1,11 +1,308 @@
|
||||
;; Example rlimit.edn file
|
||||
^{:refresh "30s"}
|
||||
{:default
|
||||
[[:default :window "200000/h"]]
|
||||
|
||||
;; #{:main/get-teams}
|
||||
;; [[:burst :bucket "5/5/5s"]]
|
||||
;; ═══════════════════════════════════════════════
|
||||
;; Auth & Identity — public, unauthenticated
|
||||
;; ═══════════════════════════════════════════════
|
||||
#{:main/login-with-password}
|
||||
[[:auth-password :bucket "100/50/1m"]]
|
||||
|
||||
;; #{:main/get-profile}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
#{: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"]]}
|
||||
+249
-78
@@ -42,31 +42,52 @@
|
||||
;; 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")
|
||||
rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||
(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)]
|
||||
|
||||
(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)]
|
||||
(-> 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)))
|
||||
|
||||
(-> 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)))))
|
||||
(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))))))
|
||||
|
||||
(def ^:private default-oidc-scopes
|
||||
#{"openid" "profile" "email"})
|
||||
@@ -107,16 +128,29 @@
|
||||
|
||||
(defn- fetch-oidc-jwks
|
||||
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
|
||||
(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))))
|
||||
(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)))))
|
||||
|
||||
(defn- populate-jwks
|
||||
"Fetch and Add (if possible) JWK's to the OIDC provider"
|
||||
"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."
|
||||
[cfg provider]
|
||||
(try
|
||||
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
|
||||
@@ -124,20 +158,28 @@
|
||||
(assoc provider :jwks jwks)
|
||||
provider)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unable to fetch JWKs for the OIDC provider"
|
||||
:provider (str (:id provider))
|
||||
:cause cause)
|
||||
provider)))
|
||||
(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)))))
|
||||
|
||||
(defn- prepare-oidc-provider
|
||||
[cfg params]
|
||||
(when-not (and (string? (:base-uri params))
|
||||
(string? (:client-id params))
|
||||
(string? (:client-secret params)))
|
||||
(ex/raise :type ::internal
|
||||
:code :invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
(raise-invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
|
||||
(try
|
||||
(if (and (string? (:token-uri params))
|
||||
@@ -150,11 +192,13 @@
|
||||
(with-meta provider {::discovered true})))
|
||||
|
||||
(catch Throwable cause
|
||||
(ex/raise :type ::internal
|
||||
:type :invalid-sso-config
|
||||
:hint "unexpected exception on configuring provider"
|
||||
:provider (:id params)
|
||||
:cause 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)))))
|
||||
|
||||
(defmethod ig/assert-key ::providers/generic
|
||||
[_ params]
|
||||
@@ -322,10 +366,9 @@
|
||||
[cfg params]
|
||||
(when-not (and (string? (:client-id params))
|
||||
(string? (:client-secret params)))
|
||||
(ex/raise :type ::internal
|
||||
:code :invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
(raise-invalid-sso-config
|
||||
:hint "missing params for provider initialization"
|
||||
:provider (:id params)))
|
||||
|
||||
(try
|
||||
(let [provider (populate-jwks cfg params)]
|
||||
@@ -336,11 +379,13 @@
|
||||
:client-secret (d/obfuscate-string (:client-secret provider)))
|
||||
provider)
|
||||
(catch Throwable cause
|
||||
(ex/raise :type ::internal
|
||||
:type :invalid-sso-config
|
||||
:hint "unexpected exception on configuring provider"
|
||||
:provider (:id params)
|
||||
:cause 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)))))
|
||||
|
||||
(defmethod ig/init-key ::providers/gitlab
|
||||
[_ cfg]
|
||||
@@ -620,9 +665,6 @@
|
||||
(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))
|
||||
@@ -650,6 +692,15 @@
|
||||
(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
|
||||
@@ -765,6 +816,82 @@
|
||||
;; 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))
|
||||
@@ -785,7 +912,10 @@
|
||||
:base-uri (some-> (non-blank-uri issuer)
|
||||
(str/rtrim "/")
|
||||
(str "/"))
|
||||
:scopes default-oidc-scopes}))
|
||||
:scopes default-oidc-scopes
|
||||
;; Organization SSO is configured by customers; discovery
|
||||
;; and JWKS failures must surface as controlled errors.
|
||||
:strict-jwks? true}))
|
||||
|
||||
(defn build-organization-sso-auth-redirect-uri
|
||||
"Build the OIDC authorization redirect URI for an organization SSO config.
|
||||
@@ -795,16 +925,24 @@
|
||||
issuer (organization-sso-discovery-uri sso)
|
||||
dest-url (or dest-url (str (cf/get :public-uri)))]
|
||||
(when-not issuer
|
||||
(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))))
|
||||
(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))))))
|
||||
|
||||
(def ^:private probe-auth-code "penpot-sso-config-probe")
|
||||
|
||||
@@ -887,10 +1025,53 @@
|
||||
{::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 {:minutes 15})]
|
||||
(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)]
|
||||
(redirect-with-error "unable-to-auth" error)
|
||||
(do
|
||||
(submit-organization-sso-oauth-failed-event cfg request (:state params) error)
|
||||
(redirect-with-error "unable-to-auth" error))
|
||||
(try
|
||||
(let [code (get params :code)
|
||||
state (get params :state)
|
||||
@@ -898,18 +1079,8 @@
|
||||
|
||||
;; Organization SSO flow: state carries :dest-url — exchange the authorization
|
||||
;; code with the OIDC provider to verify authentication actually occurred.
|
||||
(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))
|
||||
(if (:dest-url state)
|
||||
(organization-sso-callback-handler cfg request state code)
|
||||
|
||||
(let [provider (resolve-provider cfg state)
|
||||
info (get-info cfg provider state code)
|
||||
|
||||
@@ -723,6 +723,7 @@
|
||||
(-> (select-keys file file-attrs)
|
||||
(assoc :data nil)
|
||||
(dissoc :team-id)
|
||||
(dissoc :metadata)
|
||||
(dissoc :migrations)))
|
||||
|
||||
(defn- file->file-data-params
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
params {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:refer "penpot"
|
||||
:referer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}]
|
||||
(write-entry! output "manifest.json" params))))
|
||||
@@ -734,7 +734,7 @@
|
||||
:plugin-data plugin-data}))
|
||||
|
||||
(defn- import-file
|
||||
[{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}]
|
||||
[{:keys [::db/conn ::bfc/project-id ::manifest] :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)
|
||||
@@ -801,8 +801,10 @@
|
||||
(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)]
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
:redis-uri "redis://redis/0"
|
||||
|
||||
:file-data-backend "legacy-db"
|
||||
:file-data-backend "db"
|
||||
|
||||
:objects-storage-backend "fs"
|
||||
:objects-storage-fs-directory "assets"
|
||||
@@ -194,6 +194,7 @@
|
||||
[: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]
|
||||
|
||||
@@ -505,13 +505,13 @@
|
||||
:schema schema:request-file-access))
|
||||
|
||||
(def request-file-access-yourpenpot
|
||||
"File access on Your Penpot request email."
|
||||
"File access on Personal Projects request email."
|
||||
(template-factory
|
||||
:id ::request-file-access-yourpenpot
|
||||
:schema schema:request-file-access))
|
||||
|
||||
(def request-file-access-yourpenpot-view
|
||||
"File access on Your Penpot view mode request email."
|
||||
"File access on Personal Projects view mode request email."
|
||||
(template-factory
|
||||
:id ::request-file-access-yourpenpot-view
|
||||
:schema schema:request-file-access))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[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]
|
||||
@@ -159,15 +160,17 @@
|
||||
:content-type "application/octet-stream"
|
||||
:file-id file-id
|
||||
:id id})
|
||||
metadata {:storage-ref-id (:id sobject)}
|
||||
metadata (-> (:metadata params)
|
||||
(assoc :storage-ref-id (:id sobject)))
|
||||
params (-> params
|
||||
(assoc :metadata metadata)
|
||||
(assoc :data nil))]
|
||||
(upsert-in-database cfg params))
|
||||
|
||||
(= backend "db")
|
||||
(->> (dissoc params :metadata)
|
||||
(upsert-in-database cfg))
|
||||
(let [metadata (dissoc (:metadata params) :storage-ref-id)
|
||||
params (assoc params :metadata metadata)]
|
||||
(upsert-in-database cfg params))
|
||||
|
||||
(= backend "legacy-db")
|
||||
(cond
|
||||
@@ -213,18 +216,11 @@
|
||||
[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)
|
||||
(decode-metadata-with-schema)))
|
||||
(ctf/decode-file-metadata)))
|
||||
|
||||
(def ^:private schema:update-params
|
||||
[:map {:closed true}
|
||||
@@ -232,7 +228,7 @@
|
||||
[:type [:enum "main" "snapshot" "fragment"]]
|
||||
[:file-id ::sm/uuid]
|
||||
[:backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
||||
[:metadata {:optional true} [:maybe schema:metadata]]
|
||||
[:metadata {:optional true} ctf/schema:file-metadata]
|
||||
[:data {:optional true} bytes?]
|
||||
[:created-at {:optional true} ::ct/inst]
|
||||
[:modified-at {:optional true} [:maybe ::ct/inst]]
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(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]
|
||||
@@ -15,6 +14,7 @@
|
||||
[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,6 +41,12 @@
|
||||
(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}))
|
||||
@@ -49,12 +55,16 @@
|
||||
[{: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})]
|
||||
{: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"))]
|
||||
{::yres/status 307
|
||||
::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))}}))
|
||||
::yres/headers headers}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path ::cache-max-age]} obj]
|
||||
@@ -62,9 +72,12 @@
|
||||
purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||
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"))]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
|
||||
@@ -82,17 +95,32 @@
|
||||
(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]
|
||||
(or (some? (::session/profile-id request))
|
||||
(some? (::actoken/profile-id 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))))
|
||||
|
||||
(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."
|
||||
via session cookie or access token.
|
||||
For tempfile bucket, also requires ownership (profile-id match)."
|
||||
[{:keys [::sto/storage] :as cfg} request]
|
||||
(let [id (get-id request)
|
||||
obj (sto/get-object storage id)]
|
||||
@@ -104,6 +132,10 @@
|
||||
(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))))
|
||||
|
||||
@@ -118,7 +150,8 @@
|
||||
(let [file-id (:file-id mobj)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
perms (bfc/get-file-permissions pool profile-id file-id)]
|
||||
share-id (get-share-id request)
|
||||
perms (perms/get-file-read-permissions pool profile-id file-id share-id)]
|
||||
(if-not (:can-read perms)
|
||||
{::yres/status 404}
|
||||
(let [sobj (sto/get-object storage (kf mobj))]
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
[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]
|
||||
@@ -28,6 +31,7 @@
|
||||
[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]
|
||||
@@ -130,7 +134,7 @@
|
||||
:hint "invalid button"))
|
||||
|
||||
(ex/raise :type :not-found
|
||||
:code :enpty-data
|
||||
:code :empty-data
|
||||
:hint "empty response"))))
|
||||
|
||||
(defn- is-file-exists?
|
||||
@@ -484,6 +488,89 @@
|
||||
{::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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -578,5 +665,7 @@
|
||||
{: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-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]
|
||||
["/file-validate" {:handler (partial validate-file cfg)}]
|
||||
["/file-repair" {:handler (partial repair-file cfg)}]]]])
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
(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)))
|
||||
@@ -136,6 +142,7 @@
|
||||
(l/error :hint "assertion error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body (-> data
|
||||
(strip-internal-fields)
|
||||
(assoc :type :server-error)
|
||||
(assoc :code :assertion))})))))
|
||||
|
||||
@@ -161,9 +168,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))
|
||||
(assoc :hint (ex-message error)))})))
|
||||
(update :code #(or % :unhandled)))})))
|
||||
|
||||
(defmethod handle-error :default
|
||||
[error request parent-cause]
|
||||
@@ -178,6 +185,20 @@
|
||||
(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)
|
||||
@@ -190,20 +211,19 @@
|
||||
{::yres/status 504
|
||||
::yres/body {:type :server-error
|
||||
:code :statement-timeout
|
||||
:hint (ex-message error)}}
|
||||
:hint (pgsql-state->message state)}}
|
||||
|
||||
(= state "25P03")
|
||||
{::yres/status 504
|
||||
::yres/body {:type :server-error
|
||||
:code :idle-in-transaction-timeout
|
||||
:hint (ex-message error)}}
|
||||
:hint (pgsql-state->message state)}}
|
||||
|
||||
:else
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)
|
||||
:state state}}))))
|
||||
:code :database-error
|
||||
:hint (pgsql-state->message state)}}))))
|
||||
|
||||
(defmethod handle-exception :default
|
||||
[error request parent-cause]
|
||||
@@ -216,17 +236,16 @@
|
||||
(l/error :hint "unexpected error" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)}})
|
||||
:code :unexpected}})
|
||||
|
||||
: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))
|
||||
(assoc :hint (ex-message error)))}))))
|
||||
(update :code #(or % :unhandled)))}))))
|
||||
|
||||
(defmethod handle-exception java.io.IOException
|
||||
[cause request _]
|
||||
@@ -234,9 +253,7 @@
|
||||
(l/wrn :hint "io exception" :cause cause)
|
||||
{::yres/status 500
|
||||
::yres/body {:type :server-error
|
||||
:code :io-exception
|
||||
:hint (ex-message cause)
|
||||
:path (:path request)}}))
|
||||
:code :io-exception}}))
|
||||
|
||||
(defmethod handle-exception java.util.concurrent.CompletionException
|
||||
[cause request _]
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
[:enum
|
||||
"customer_service"
|
||||
"low_quality"
|
||||
"missing_feature"
|
||||
"missing_features"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
(:import
|
||||
io.undertow.server.RequestTooBigException
|
||||
java.io.InputStream
|
||||
java.io.OutputStream))
|
||||
java.io.OutputStream
|
||||
java.security.MessageDigest))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -82,18 +83,18 @@
|
||||
(instance? IllegalArgumentException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)
|
||||
:hint "invalid JSON in request body"
|
||||
:cause cause)
|
||||
|
||||
(instance? RequestTooBigException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :request-body-too-large
|
||||
:hint (ex-message cause))
|
||||
:hint "request body exceeds size limit")
|
||||
|
||||
(instance? java.io.EOFException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)
|
||||
:hint "unexpected end of request body"
|
||||
:cause cause)
|
||||
|
||||
(instance? RuntimeException cause)
|
||||
@@ -329,6 +330,11 @@
|
||||
{: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)
|
||||
@@ -338,7 +344,7 @@
|
||||
(let [key-id (-> key-id str/lower keyword)]
|
||||
(if (and (string? key)
|
||||
(contains? keys key-id)
|
||||
(= key (get keys key-id)))
|
||||
(constant-time-eq? 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 ::id) (delete-session manager))
|
||||
(some->> (get request ::session) :id (delete-session manager))
|
||||
(clear-session-cookie response)))
|
||||
|
||||
(defn decode-token
|
||||
@@ -226,6 +226,14 @@
|
||||
(-> (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,6 +7,7 @@
|
||||
(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]
|
||||
@@ -17,6 +18,8 @@
|
||||
[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]
|
||||
@@ -131,8 +134,9 @@
|
||||
(mbus/pub! msgbus :topic topic :message msg))))
|
||||
|
||||
(defmethod handle-message :subscribe-team
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-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)))]
|
||||
@@ -150,8 +154,10 @@
|
||||
|
||||
|
||||
(defmethod handle-message :subscribe-file
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
[{:keys [::mbus/msgbus ::db/pool]} {: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,6 +36,16 @@
|
||||
(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
|
||||
@@ -297,6 +307,14 @@
|
||||
(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,6 +7,7 @@
|
||||
(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]
|
||||
@@ -25,7 +26,7 @@
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [id] :as report}]
|
||||
(let [type (get report :type)
|
||||
text (str "#" type " | " (get report :hint) "\n"
|
||||
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
|
||||
(when id
|
||||
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
"- tenant: #" (:tenant report) "\n"
|
||||
"- origin: #" (:origin report) "\n"
|
||||
(when-let [href (get report :href)]
|
||||
(str "- href: `" href "`\n"))
|
||||
(str "- href: `" (d/escape-markdown href) "`\n"))
|
||||
(when-let [version (get report :frontend-version)]
|
||||
(str "- frontend-version: `" version "`\n"))
|
||||
(when-let [version (get report :backend-version)]
|
||||
|
||||
+57
-11
@@ -167,6 +167,9 @@
|
||||
[: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
|
||||
@@ -242,7 +245,7 @@
|
||||
[:enum
|
||||
"customer_service"
|
||||
"low_quality"
|
||||
"missing_feature"
|
||||
"missing_features"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
@@ -259,6 +262,14 @@
|
||||
(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
|
||||
@@ -489,6 +500,7 @@
|
||||
[_ 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)
|
||||
@@ -596,22 +608,25 @@
|
||||
: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)
|
||||
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))
|
||||
(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))
|
||||
(catch Throwable cause
|
||||
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||
(throw cause)
|
||||
@@ -621,6 +636,23 @@
|
||||
: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.
|
||||
@@ -637,3 +669,17 @@
|
||||
: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))))
|
||||
+26
-14
@@ -261,23 +261,28 @@
|
||||
(defn- wrap-nitrate-sso
|
||||
"Enforce Nitrate organization SSO authentication for RPC handlers.
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
Once the context is resolved, checks if the user is authorized within that organization's
|
||||
SSO session using nitrate/sso-session-authorized?. Authorized results are cached
|
||||
by [profile-id cache-ref] for 15 minutes to avoid repeated lookups.
|
||||
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.
|
||||
|
||||
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."
|
||||
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."
|
||||
[_ f mdata]
|
||||
(if (and (contains? cf/flags :admin-console)
|
||||
(::auth mdata true) ;; only for endpoints that needs auth
|
||||
@@ -302,17 +307,22 @@
|
||||
cached (cache/get organization-sso-auth-cache cache-key)
|
||||
result (if (some? cached)
|
||||
cached
|
||||
(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]})))
|
||||
;; 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
|
||||
(: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 (:organization-id sso)}]
|
||||
:organization-id (or (:organization-id sso) organization-id)
|
||||
:team-id team-id}]
|
||||
(when authorized
|
||||
(cache/get organization-sso-auth-cache cache-key (constantly entry)))
|
||||
entry))]
|
||||
@@ -320,6 +330,8 @@
|
||||
(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,6 +183,7 @@
|
||||
|
||||
(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 (d/normalize-string theme)
|
||||
theme (some-> theme d/normalize-string not-empty)
|
||||
|
||||
photo-id (some->> (or (:oidc/picture props)
|
||||
(:google/picture props)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
[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]
|
||||
@@ -59,7 +60,7 @@
|
||||
{::sto/content data
|
||||
::sto/touched-at (ct/in-future {:minutes 60})
|
||||
:content-type "application/zip"
|
||||
:bucket "tempfile"})]
|
||||
:bucket sto/tempfile-bucket})]
|
||||
|
||||
(-> (cf/get :public-uri)
|
||||
(u/join "/assets/by-id/")
|
||||
@@ -104,7 +105,11 @@
|
||||
(try
|
||||
(case (int version)
|
||||
1 (bf.v1/import-files! cfg)
|
||||
3 (bf.v3/import-files! cfg))
|
||||
3 (bf.v3/import-files! cfg)
|
||||
(throw (ex-info (str "Unsupported binfile version: " version)
|
||||
{:type :validation
|
||||
:code :unsupported-version
|
||||
:version version})))
|
||||
(finally
|
||||
(when owned?
|
||||
(fs/delete input-path))))]
|
||||
@@ -118,11 +123,11 @@
|
||||
|
||||
(def ^:private schema:import-binfile
|
||||
[:and
|
||||
[:map {:title "import-binfile" :closed true}
|
||||
[:map {:title "import-binfile"}
|
||||
[:name [:or [:string {:max 250}]
|
||||
[:map-of ::sm/uuid [:string {:max 250}]]]]
|
||||
[:project-id ::sm/uuid]
|
||||
[:version {:optional true} ::sm/int]
|
||||
[:version {:optional true} [:enum 1 3]]
|
||||
[:file {:optional true} media.v/schema:upload]
|
||||
[:upload-id {:optional true} ::sm/uuid]]
|
||||
[:fn {:error/message "one of :file or :upload-id is required"}
|
||||
@@ -142,24 +147,33 @@
|
||||
|
||||
::webhooks/event? true
|
||||
::sse/stream? true
|
||||
::sm/params schema:import-binfile}
|
||||
::sm/params schema:import-binfile
|
||||
::climit/id [[:import-binfile/by-profile ::rpc/profile-id]
|
||||
[:import-binfile/global]]}
|
||||
[{: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 [version (or version 3)
|
||||
(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))
|
||||
|
||||
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)))]
|
||||
3 (bf.v3/get-manifest (-> params :file :path))
|
||||
(throw (ex-info (str "Unsupported binfile version: " version)
|
||||
{:type :validation
|
||||
:code :unsupported-version
|
||||
:version version})))]
|
||||
|
||||
(with-meta
|
||||
(sse/response (partial import-binfile cfg params))
|
||||
|
||||
@@ -245,6 +245,10 @@
|
||||
[cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
|
||||
(db/run! cfg (fn [cfg]
|
||||
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
|
||||
(when (= :share-link (:type perms))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:hint "object not found"))
|
||||
(check-read-permissions! perms)
|
||||
(-> (get-file-fragment cfg file-id fragment-id)
|
||||
(rph/with-http-cache long-cache-duration))))))
|
||||
@@ -392,6 +396,14 @@
|
||||
(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)})
|
||||
@@ -402,8 +414,7 @@
|
||||
(cfeat/check-file-features! (:features file)))
|
||||
|
||||
page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
|
||||
(let [page-id (or page-id (-> file :data :pages first))
|
||||
page (dm/get-in file [:data :pages-index page-id])]
|
||||
(let [page (dm/get-in file [:data :pages-index resolved-page-id])]
|
||||
(if (pmap/pointer-map? page)
|
||||
(deref page)
|
||||
page)))]
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
(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]
|
||||
@@ -43,7 +45,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/next)
|
||||
{:id (uuid/random)
|
||||
:file-id file-id
|
||||
:who-comment who-comment
|
||||
:who-inspect who-inspect
|
||||
@@ -66,5 +68,16 @@
|
||||
[{: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,6 +93,18 @@
|
||||
|
||||
(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]
|
||||
@@ -106,10 +118,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 [uploads] :as params}]
|
||||
[cfg {:keys [::rpc/profile-id uploads] :as params}]
|
||||
(let [data (reduce-kv
|
||||
(fn [acc mtype session-id]
|
||||
(let [assembled (assemble-chunks cfg session-id)]
|
||||
(let [assembled (assemble-chunks cfg profile-id session-id)]
|
||||
(-> {:mtype mtype :size (:size assembled)}
|
||||
(media.v/validate-media-type! cm/font-types)
|
||||
(media.v/validate-font-size!))
|
||||
@@ -132,8 +144,9 @@
|
||||
[:process-font/global]]
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-font-variant}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-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})
|
||||
@@ -340,7 +353,7 @@
|
||||
::sto/touched-at (ct/in-future {:minutes 30})
|
||||
:profile-id profile-id
|
||||
:content-type mtype
|
||||
:bucket "tempfile"}]
|
||||
:bucket sto/tempfile-bucket}]
|
||||
|
||||
(sto/put-object! storage content)))
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
|
||||
(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]
|
||||
@@ -58,6 +64,12 @@
|
||||
(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
|
||||
@@ -272,8 +284,13 @@
|
||||
(clone-file-media-object cfg params))
|
||||
|
||||
(defn clone-file-media-object
|
||||
[{:keys [::db/conn]} {:keys [id file-id is-local]}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}]
|
||||
(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
|
||||
@@ -289,7 +306,7 @@
|
||||
|
||||
(def ^:private schema:create-upload-session
|
||||
[:map {:title "create-upload-session"}
|
||||
[:total-chunks ::sm/int]])
|
||||
[:total-chunks [::sm/int {:min 1}]]])
|
||||
|
||||
(def ^:private schema:create-upload-session-result
|
||||
[:map {:title "create-upload-session-result"}
|
||||
@@ -362,7 +379,7 @@
|
||||
::sto/deduplicate? false
|
||||
::sto/touch true
|
||||
:content-type (:mtype content)
|
||||
:bucket "tempfile"
|
||||
:bucket sto/tempfile-bucket
|
||||
:upload-id (str session-id)
|
||||
:chunk-index index}))
|
||||
|
||||
@@ -402,9 +419,10 @@
|
||||
|
||||
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} session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id})
|
||||
[{:keys [::db/conn] :as cfg} profile-id session-id]
|
||||
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
|
||||
chunks (get-upload-chunks conn session-id)]
|
||||
|
||||
(when (not= (count chunks) (:total-chunks session))
|
||||
@@ -447,7 +465,7 @@
|
||||
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [content (assemble-chunks cfg session-id)
|
||||
(let [content (assemble-chunks cfg profile-id session-id)
|
||||
content (-> content
|
||||
(assoc :filename (str "upload:" name))
|
||||
(assoc :mtype mtype)
|
||||
|
||||
@@ -41,17 +41,6 @@
|
||||
(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"}
|
||||
@@ -59,7 +48,7 @@
|
||||
|
||||
(sv/defmethod ::get-nitrate-connectivity
|
||||
{::rpc/auth true
|
||||
::doc/added "2.14"
|
||||
::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:connectivity}
|
||||
[cfg _params]
|
||||
@@ -75,7 +64,7 @@
|
||||
|
||||
(sv/defmethod ::get-subscription-warning
|
||||
{::rpc/auth true
|
||||
::doc/added "2.14"
|
||||
::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:subscription-warning}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
@@ -91,7 +80,7 @@
|
||||
|
||||
(sv/defmethod ::redeem-nitrate-activation-code
|
||||
{::rpc/auth true
|
||||
::doc/added "2.14"
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:redeem-activation-code-params
|
||||
::sm/result schema:redeem-activation-code-result}
|
||||
[cfg {:keys [::rpc/profile-id activation-code]}]
|
||||
@@ -112,6 +101,7 @@
|
||||
(ex/raise :type :validation
|
||||
:code (case status
|
||||
410 :expired-activation-code
|
||||
409 :used-activation-code
|
||||
:invalid-activation-code)
|
||||
:cause cause)
|
||||
(throw cause)))))))
|
||||
@@ -123,7 +113,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.20"
|
||||
::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result ::sm/text}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
@@ -335,7 +325,7 @@
|
||||
(when-not skip-validation
|
||||
(assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave))
|
||||
|
||||
(assert-membership cfg profile-id id)
|
||||
(nitrate/assert-membership cfg profile-id id)
|
||||
|
||||
;; delete only eligible teams (non-protected and without files)
|
||||
(doseq [id deletable-team-ids]
|
||||
@@ -346,7 +336,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 "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||
;; Process organization "Personal Projects" 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])
|
||||
@@ -371,7 +361,7 @@
|
||||
|
||||
(sv/defmethod ::leave-organization
|
||||
{::rpc/auth true
|
||||
::doc/added "2.15"
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:leave-organization
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||
@@ -415,13 +405,13 @@
|
||||
[:organization-name ::sm/text]])
|
||||
|
||||
(sv/defmethod ::remove-team-from-organization
|
||||
{::doc/added "2.17"
|
||||
{::doc/added "2.18"
|
||||
::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)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/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
|
||||
@@ -468,13 +458,14 @@
|
||||
(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}))
|
||||
external-emails (->> profiles
|
||||
(remove #(contains? organization-member-ids (:id %)))
|
||||
(map :email)
|
||||
(vec))]
|
||||
member-emails (->> profiles
|
||||
(filter #(contains? organization-member-ids (:id %)))
|
||||
(map :email)
|
||||
(into #{}))
|
||||
external-emails (into [] (remove member-emails emails))]
|
||||
{:allows-anybody false :external-emails external-emails}))))))
|
||||
|
||||
(def ^:private schema:add-team-to-organization
|
||||
@@ -484,14 +475,14 @@
|
||||
|
||||
(sv/defmethod ::add-team-to-organization
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::doc/added "2.18"
|
||||
::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)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/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}))
|
||||
@@ -569,13 +560,13 @@
|
||||
|
||||
(sv/defmethod ::check-organization-members
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::doc/added "2.18"
|
||||
::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)
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/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)
|
||||
@@ -594,7 +585,7 @@
|
||||
|
||||
(sv/defmethod ::all-organization-members-in-team
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:all-organization-members-in-team-params
|
||||
::sm/result ::sm/boolean}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
|
||||
@@ -603,7 +594,7 @@
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/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})
|
||||
@@ -618,7 +609,7 @@
|
||||
|
||||
(sv/defmethod ::all-team-members-in-organizations
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::doc/added "2.18"
|
||||
::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]}]
|
||||
@@ -631,7 +622,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! #(assert-membership cfg profile-id %) organization-ids)
|
||||
(run! #(nitrate/assert-membership cfg profile-id %) organization-ids)
|
||||
|
||||
(into {}
|
||||
(map (fn [organization-id]
|
||||
@@ -654,7 +645,7 @@
|
||||
|
||||
(sv/defmethod ::check-team-external-invitations
|
||||
{::rpc/auth true
|
||||
::doc/added "2.17"
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:check-team-external-invitations-params
|
||||
::sm/result schema:check-team-external-invitations-result
|
||||
::db/transaction true}
|
||||
@@ -664,7 +655,7 @@
|
||||
(when-not (or (:is-admin perms) (:is-owner perms))
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
(assert-membership cfg profile-id organization-id)
|
||||
(nitrate/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}))
|
||||
@@ -683,12 +674,17 @@
|
||||
(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} when SSO is not active or the user cannot access the team.
|
||||
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 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."
|
||||
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."
|
||||
{::rpc/auth true
|
||||
::doc/added "2.19"
|
||||
::doc/added "2.18"
|
||||
::sm/params schema:check-nitrate-sso
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}]
|
||||
@@ -697,16 +693,25 @@
|
||||
(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}
|
||||
{:authorized true :reason :no-team-access}
|
||||
(let [request (rph/get-request params)
|
||||
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)]
|
||||
(if authorized
|
||||
{:authorized true}
|
||||
{:authorized true :reason :sso-satisfied}
|
||||
(if (oidc/organization-sso-discovery-uri sso)
|
||||
{:authorized false
|
||||
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
|
||||
:dest-url url
|
||||
:organization-id organization-id)}
|
||||
(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 nil}))))
|
||||
{:authorized true}))
|
||||
{:authorized true :reason :sso-satisfied}))
|
||||
@@ -166,8 +166,12 @@
|
||||
;; the same row/object.
|
||||
(let [profile (get-profile conn profile-id ::db/for-update true)
|
||||
fullname (d/normalize-string fullname)
|
||||
lang (d/normalize-string lang)
|
||||
theme (d/normalize-string theme)
|
||||
lang (if (contains? params :lang)
|
||||
(d/normalize-string lang)
|
||||
(:lang profile))
|
||||
theme (if (contains? params :theme)
|
||||
(d/normalize-string theme)
|
||||
(:theme profile))
|
||||
;; Update the profile map with direct params
|
||||
profile (-> profile
|
||||
(assoc :fullname fullname)
|
||||
@@ -520,7 +524,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 "Your Penpot" teams according to whether they still have files.
|
||||
;; imported "Personal Projects" 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)
|
||||
@@ -534,6 +538,10 @@
|
||||
: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)]
|
||||
(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])))))
|
||||
(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))))
|
||||
|
||||
(def ^:private sql:get-owned-teams
|
||||
"SELECT t.id, t.name,
|
||||
@@ -538,6 +538,9 @@
|
||||
;; 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)
|
||||
@@ -572,7 +575,7 @@
|
||||
(set/difference cfeat/frontend-only-features)
|
||||
(set/difference cfeat/no-team-inheritable-features))
|
||||
params {:profile-id profile-id
|
||||
:name "Your Penpot"
|
||||
:name "Personal Projects"
|
||||
:features features
|
||||
:organization-id organization-id
|
||||
:is-default true}
|
||||
@@ -826,7 +829,7 @@
|
||||
:code :only-owner-can-delete-team))
|
||||
|
||||
;; Protect the user's personal default team from deletion.
|
||||
;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
|
||||
;; Organization-scoped default teams ("Personal Projects") 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
|
||||
|
||||
@@ -108,14 +108,7 @@
|
||||
(def ^:private schema:create-organization-invitation
|
||||
[:map {:title "params:create-organization-invitation"}
|
||||
[::rpc/profile-id ::sm/uuid]
|
||||
[:organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:initials [:maybe :string]]
|
||||
[:logo ::sm/uri]
|
||||
[:avatar-bg-url [:maybe ::sm/uri]]
|
||||
[:sso-active [:maybe ::sm/boolean]]]]
|
||||
[:organization cto/schema:organization-with-avatar]
|
||||
[:profile
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
@@ -467,6 +460,10 @@
|
||||
[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
|
||||
@@ -482,6 +479,24 @@
|
||||
(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
|
||||
@@ -588,7 +603,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-read-permissions! cfg profile-id team-id)
|
||||
(teams/check-edition-permissions! cfg profile-id team-id)
|
||||
(let [email (profile/clean-email email)
|
||||
invit (-> (db/get pool :team-invitation
|
||||
{:team-id team-id
|
||||
@@ -625,6 +640,11 @@
|
||||
(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)})
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
(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 :refer [schema:upload]]
|
||||
[app.media.validation :as media.v]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.storage :as sto]
|
||||
@@ -21,7 +22,7 @@
|
||||
(def ^:private
|
||||
schema:upload-tempfile-params
|
||||
[:map {:title "upload-templfile-params"}
|
||||
[:content schema:upload]])
|
||||
[:content media.v/schema:upload]])
|
||||
|
||||
(def ^:private
|
||||
schema:upload-tempfile-result
|
||||
@@ -32,6 +33,7 @@
|
||||
::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))
|
||||
@@ -41,7 +43,7 @@
|
||||
::sto/touched-at (ct/in-future {:minutes 10})
|
||||
:profile-id profile-id
|
||||
:content-type (:mtype content)
|
||||
:bucket "tempfile"}
|
||||
:bucket sto/tempfile-bucket}
|
||||
object (sto/put-object! storage content)]
|
||||
{:id (:id object)
|
||||
:uri (-> (cf/get :public-uri)
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
[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]
|
||||
@@ -54,7 +56,7 @@
|
||||
|
||||
(sv/defmethod ::authenticate
|
||||
"Authenticate the current user"
|
||||
{::doc/added "2.14"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
@@ -94,7 +96,7 @@
|
||||
|
||||
(sv/defmethod ::get-penpot-version
|
||||
"Get the current Penpot version"
|
||||
{::doc/added "2.14"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-penpot-version-result
|
||||
::rpc/auth false}
|
||||
@@ -106,7 +108,7 @@
|
||||
|
||||
(sv/defmethod ::get-teams
|
||||
"List teams for which current user is owner"
|
||||
{::doc/added "2.14"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-teams-result
|
||||
::nitrate/sso false}
|
||||
@@ -130,11 +132,12 @@
|
||||
"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.17"
|
||||
{::doc/added "2.18"
|
||||
::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))
|
||||
@@ -151,7 +154,7 @@
|
||||
|
||||
(sv/defmethod ::notify-team-change
|
||||
"Notify to Penpot a team change from nitrate"
|
||||
{::doc/added "2.14"
|
||||
{::doc/added "2.18"
|
||||
::sm/params cto/schema:team-with-organization
|
||||
::rpc/auth false}
|
||||
[cfg team]
|
||||
@@ -168,7 +171,7 @@
|
||||
|
||||
(sv/defmethod ::notify-user-added-to-organization
|
||||
"Notify to Penpot that an user has joined an organization from nitrate"
|
||||
{::doc/added "2.14"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-added-to-organization
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [profile-id organization-id]}]
|
||||
@@ -199,7 +202,7 @@
|
||||
|
||||
(sv/defmethod ::get-managed-profiles
|
||||
"List profiles that belong to teams for which current user is owner"
|
||||
{::doc/added "2.14"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map]
|
||||
::sm/result schema:managed-profile-result
|
||||
::nitrate/sso false}
|
||||
@@ -239,7 +242,7 @@
|
||||
|
||||
(sv/defmethod ::get-teams-summary
|
||||
"Get summary information for a list of teams"
|
||||
{::doc/added "2.15"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:get-teams-summary-params
|
||||
::sm/result schema:get-teams-summary-result
|
||||
::nitrate/sso false}
|
||||
@@ -315,7 +318,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a deleted organization, preserve organization teams unchanged and only prefix or
|
||||
delete member Your Penpot teams depending on whether they still contain files."
|
||||
delete member Personal Projects teams depending on whether they still contain files."
|
||||
[cfg {:keys [organization-id organization-name teams]}]
|
||||
(let [all-team-ids (->> teams
|
||||
(map :id)
|
||||
@@ -344,13 +347,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
|
||||
;; Your Penpot teams keep the organization prefix when they still have files.
|
||||
;; Personal Projects 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 Your Penpot teams disappear entirely.
|
||||
;; Empty imported Personal Projects 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)
|
||||
@@ -359,8 +362,8 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::notify-organization-deletion
|
||||
"For a deleted organization, preserve organization teams and only prefix or delete
|
||||
imported Your Penpot teams before notifying connected users."
|
||||
{::doc/added "2.15"
|
||||
imported Personal Projects before notifying connected users."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
@@ -379,7 +382,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 Your Penpot teams."
|
||||
transfer rules to their imported Personal Projects teams."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-organizations-deletion
|
||||
::nitrate/sso false}
|
||||
@@ -406,7 +409,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::get-profile-by-email
|
||||
"Get profile by email"
|
||||
{::doc/added "2.15"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map [:email ::sm/email]]
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
@@ -430,7 +433,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::get-profile-by-id
|
||||
"Get profile by email"
|
||||
{::doc/added "2.15"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map [:id ::sm/uuid]]
|
||||
::sm/result schema:profile
|
||||
::nitrate/sso false}
|
||||
@@ -465,7 +468,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.15"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:get-organization-member-team-counts-params
|
||||
::sm/result schema:get-organization-member-team-counts-result
|
||||
::rpc/auth false}
|
||||
@@ -488,15 +491,33 @@ 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.15"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map
|
||||
[:email ::sm/email]
|
||||
[:organization cto/schema:organization-with-avatar]]
|
||||
::nitrate/sso false}
|
||||
[cfg params]
|
||||
(db/tx-run! cfg ti/create-organization-invitation params)
|
||||
[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)))
|
||||
nil)
|
||||
|
||||
|
||||
@@ -519,7 +540,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.16"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:get-organization-invitations-params
|
||||
::sm/result schema:get-organization-invitations-result
|
||||
::nitrate/sso false}
|
||||
@@ -547,7 +568,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.16"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:delete-organization-invitations-params
|
||||
::nitrate/sso false}
|
||||
[cfg {:keys [organization-id email]}]
|
||||
@@ -612,7 +633,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::remove-from-organization
|
||||
"Remove an user from an organization"
|
||||
{::doc/added "2.17"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
@@ -657,7 +678,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.17"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map
|
||||
[:profile-id ::sm/uuid]
|
||||
[:organization-id ::sm/uuid]
|
||||
@@ -692,7 +713,7 @@ RETURNING id, deleted_at;")
|
||||
|
||||
(sv/defmethod ::send-renewal-email
|
||||
"Send an Enterprise subscription renewal notice email to a user."
|
||||
{::doc/added "2.17"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:send-renewal-email-params
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
|
||||
@@ -805,7 +826,7 @@ RETURNING id, deleted_at;")
|
||||
"Push audit events from nitrate (strictly for nitrate backend
|
||||
events)"
|
||||
|
||||
{::doc/added "2.19"
|
||||
{::doc/added "2.18"
|
||||
::audit/skip true
|
||||
::sm/params schema:push-audit-events-params
|
||||
::rpc/auth false}
|
||||
@@ -912,7 +933,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.20"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:get-teams-detail-params
|
||||
::sm/result schema:get-teams-detail-result
|
||||
::nitrate/sso false}
|
||||
@@ -940,7 +961,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.20"
|
||||
{::doc/added "2.18"
|
||||
::sm/params cto/schema:nitrate-sso
|
||||
::sm/result schema:check-organization-sso-result
|
||||
::rpc/auth false}
|
||||
@@ -950,7 +971,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.19"
|
||||
{::doc/added "2.18"
|
||||
::sm/params [:map
|
||||
[:organization-id ::sm/uuid]
|
||||
[:updated-props ::sm/boolean]
|
||||
@@ -999,7 +1020,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.19"
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:bulk-create-profiles-params
|
||||
::sm/result schema:bulk-create-profiles-result
|
||||
::rpc/auth false}
|
||||
|
||||
@@ -546,6 +546,76 @@
|
||||
(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,6 +46,7 @@
|
||||
[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]
|
||||
@@ -180,8 +181,8 @@
|
||||
result (rds/eval rconn script)
|
||||
allowed? (boolean (nth result 0))
|
||||
remaining (nth result 1)
|
||||
reset (* (/ (inst-ms interval) rate)
|
||||
(- capacity remaining))]
|
||||
reset (long (mth/ceil (double (* (/ (inst-ms interval) rate)
|
||||
(- capacity remaining)))))]
|
||||
(l/trace :hint "limit processed"
|
||||
:method method
|
||||
:limit (name (::name limit))
|
||||
@@ -190,6 +191,7 @@
|
||||
:allowed allowed?
|
||||
:remaining remaining)
|
||||
(-> limit
|
||||
(assoc ::lresult/now now)
|
||||
(assoc ::lresult/allowed allowed?)
|
||||
(assoc ::lresult/reset (ct/plus now reset))
|
||||
(assoc ::lresult/remaining remaining))))
|
||||
@@ -212,6 +214,7 @@
|
||||
: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_feature"
|
||||
"missing_features"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
(defn process-file!
|
||||
[system file-id update-fn
|
||||
& {:keys [::snapshot-label ::validate? ::with-libraries?]
|
||||
& {:keys [::profile-id ::snapshot-label ::validate? ::with-libraries?]
|
||||
:or {validate? true} :as opts}]
|
||||
(let [file (bfc/get-file system file-id
|
||||
:lock-for-update? true
|
||||
@@ -177,8 +177,9 @@
|
||||
(when (string? snapshot-label)
|
||||
(fsnap/create! system file
|
||||
{:label snapshot-label
|
||||
:profile-id profile-id
|
||||
:deleted-at (ct/in-future {:days 30})
|
||||
:created-by "admin"}))
|
||||
:created-by "system"}))
|
||||
|
||||
(let [file' (update file' :revn inc)]
|
||||
(bfc/update-file! system file' opts)
|
||||
|
||||
@@ -398,10 +398,6 @@
|
||||
(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}]
|
||||
@@ -410,6 +406,10 @@
|
||||
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,6 +38,10 @@
|
||||
(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"
|
||||
@@ -45,7 +49,7 @@
|
||||
"file-thumbnail"
|
||||
"profile"
|
||||
"organization"
|
||||
"tempfile"
|
||||
tempfile-bucket
|
||||
"file-data"
|
||||
"file-data-fragment"
|
||||
"file-change"})
|
||||
@@ -136,7 +140,7 @@
|
||||
result (when (and (::deduplicate? params)
|
||||
(:hash mdata)
|
||||
(:bucket mdata)
|
||||
(not= "tempfile" (:bucket mdata)))
|
||||
(not= tempfile-bucket (: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 (= "tempfile" bucket)
|
||||
(let [deletion-delay (if (= sto/tempfile-bucket bucket)
|
||||
(ct/duration {:hours 2})
|
||||
(cf/get-deletion-delay))]
|
||||
(some->> (seq to-freeze) (mark-freeze-in-bulk! conn))
|
||||
@@ -158,15 +158,16 @@
|
||||
|
||||
(defn- process-bucket!
|
||||
[conn bucket objects]
|
||||
(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)
|
||||
(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
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-unknown-reference
|
||||
:hint (dm/fmt "unknown reference '%'" bucket))))
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[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]))
|
||||
|
||||
@@ -385,6 +386,9 @@
|
||||
(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
|
||||
@@ -519,6 +523,59 @@
|
||||
(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)]
|
||||
@@ -531,3 +588,138 @@
|
||||
: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))))))))
|
||||
@@ -207,6 +207,29 @@
|
||||
(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
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[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]
|
||||
@@ -36,11 +37,16 @@
|
||||
(assoc storage ::sto/backend :fs))
|
||||
|
||||
(defn- create-storage-object!
|
||||
"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"}))
|
||||
"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)))))
|
||||
|
||||
(defn- make-handler-cfg
|
||||
"Build a minimal cfg map for the assets handlers."
|
||||
@@ -588,6 +594,111 @@
|
||||
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*)
|
||||
@@ -602,3 +713,70 @@
|
||||
::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,10 +6,12 @@
|
||||
|
||||
(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]
|
||||
@@ -300,7 +302,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 (string? (-> ex ex-data :hint)))))
|
||||
(t/is (= "invalid JSON in request body" (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-request-too-big-exception
|
||||
;; When RequestTooBigException is raised (e.g. the request body
|
||||
@@ -319,7 +321,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 (string? (-> ex ex-data :hint)))))
|
||||
(t/is (= "request body exceeds size limit" (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-eof-exception
|
||||
;; When java.io.EOFException is raised (e.g. the body stream
|
||||
@@ -337,7 +339,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 (string? (-> ex ex-data :hint)))))
|
||||
(t/is (= "unexpected end of request body" (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-runtime-exception-with-cause
|
||||
;; When a RuntimeException with a non-nil ex-cause is raised,
|
||||
@@ -377,7 +379,7 @@
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :unexpected (:code body)))
|
||||
(t/is (= "boom" (:hint body)))))
|
||||
(t/is (nil? (:hint body)))))
|
||||
|
||||
(t/deftest parse-request-non-runtime-throwable
|
||||
;; When a non-RuntimeException Throwable is raised (e.g. an
|
||||
@@ -397,4 +399,45 @@
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :io-exception (:code body)))
|
||||
(t/is (= "network gone" (:hint 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)))))
|
||||
@@ -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 (th/config-get-mock {})]
|
||||
(with-redefs [cf/get (constantly nil)]
|
||||
(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"}}))]
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
;; 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,32 +11,55 @@
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.binfile :as binfile]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest import-binfile-schema-rejects-file-id
|
||||
(t/deftest import-binfile-schema-omits-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")
|
||||
|
||||
;; 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")))
|
||||
(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")))
|
||||
@@ -733,7 +733,7 @@
|
||||
(t/is (= 2 (count rows)))
|
||||
(t/is (= 1 (count (remove (comp some? :deleted-at) rows))))
|
||||
(t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame")
|
||||
(-> rows first :object-id))))
|
||||
(->> rows (remove (comp some? :deleted-at)) first :object-id))))
|
||||
|
||||
;; Now that file-gc have marked for deletion the object
|
||||
;; thumbnail lets execute the objects-gc task which remove
|
||||
@@ -2377,8 +2377,6 @@
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
;; --- Security Fix Tests ---
|
||||
|
||||
(t/deftest link-file-to-library-circular-reference
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
@@ -2448,3 +2446,251 @@
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata))))))
|
||||
|
||||
(t/deftest get-file-libraries-nonexistent-file
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-file-libraries
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-file-libraries-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)})
|
||||
out (th/command! {::th/type :get-file-libraries
|
||||
::rpc/profile-id (:id other)
|
||||
:file-id (:id file)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest share-link-deletion-idor
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
editor (th/create-profile* 2 {:is-active true})
|
||||
admin (th/create-profile* 3 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
team-id (:default-team-id owner)
|
||||
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
;; Invite editor to the team with edit permissions
|
||||
_ (th/create-team-role* {:team-id team-id
|
||||
:profile-id (:id editor)
|
||||
:role :editor})
|
||||
|
||||
;; Invite admin to the team with admin permissions
|
||||
_ (th/create-team-role* {:team-id team-id
|
||||
:profile-id (:id admin)
|
||||
:role :admin})
|
||||
|
||||
;; Owner creates a share-link
|
||||
slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{(get-in file [:data :pages 0])}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
slink-id (get-in slink [:result :id])]
|
||||
|
||||
(t/testing "owner can delete their own share-link"
|
||||
(let [out (th/command! {::th/type :delete-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:id slink-id})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "editor CANNOT delete owner's share-link (IDOR)"
|
||||
;; Recreate the share-link for this test
|
||||
(let [slink2 (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "team"})
|
||||
slink2-id (get-in slink2 [:result :id])
|
||||
|
||||
;; Editor tries to delete owner's share-link
|
||||
out (th/command! {::th/type :delete-share-link
|
||||
::rpc/profile-id (:id editor)
|
||||
:id slink2-id})
|
||||
err (:error out)
|
||||
edata (ex-data err)]
|
||||
|
||||
;; Should be denied with authorization error
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (= :authorization (:type edata)))
|
||||
|
||||
;; Verify the share-link still exists
|
||||
(let [check (th/command! {::th/type :get-view-only-bundle
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)})
|
||||
share-links (:share-links (:result check))]
|
||||
(t/is (some #(= slink2-id (:id %)) share-links)))))))
|
||||
|
||||
(t/deftest share-link-page-scope-enforcement
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
viewer (th/create-profile* 2 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
page-a (get-in file [:data :pages 0])
|
||||
page-b (uuid/random)
|
||||
|
||||
;; Add a second page to the file
|
||||
_ (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 {}}}]})
|
||||
|
||||
;; Create share-link scoped to page A only
|
||||
share (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{page-a}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
share-id (get-in share [:result :id])]
|
||||
|
||||
(t/testing "share-link holder can access authorized page"
|
||||
(let [out (th/command! {::th/type :get-page
|
||||
::rpc/profile-id (:id viewer)
|
||||
:file-id (:id file)
|
||||
:page-id page-a
|
||||
:share-id share-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (some? (:result out)))))
|
||||
|
||||
(t/testing "share-link holder cannot access out-of-scope page"
|
||||
(let [out (th/command! {::th/type :get-page
|
||||
::rpc/profile-id (:id viewer)
|
||||
:file-id (:id file)
|
||||
:page-id page-b
|
||||
:share-id share-id})
|
||||
err (:error out)
|
||||
edata (ex-data err)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (= :not-found (:type edata)))
|
||||
(t/is (= :object-not-found (:code edata)))))
|
||||
|
||||
(t/testing "team member can access all pages"
|
||||
(let [out-a (th/command! {::th/type :get-page
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:page-id page-a})
|
||||
out-b (th/command! {::th/type :get-page
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:page-id page-b})]
|
||||
(t/is (nil? (:error out-a)))
|
||||
(t/is (nil? (:error out-b)))))))
|
||||
|
||||
(t/deftest share-link-deletion-escape-hatches
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
editor (th/create-profile* 2 {:is-active true})
|
||||
admin (th/create-profile* 3 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
team-id (:default-team-id owner)
|
||||
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
;; Invite editor to the team with edit permissions
|
||||
_ (th/create-team-role* {:team-id team-id
|
||||
:profile-id (:id editor)
|
||||
:role :editor})
|
||||
|
||||
;; Invite admin to the team with admin permissions
|
||||
_ (th/create-team-role* {:team-id team-id
|
||||
:profile-id (:id admin)
|
||||
:role :admin})]
|
||||
|
||||
(t/testing "editor CAN delete their own share-link"
|
||||
(let [slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id editor)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "team"})
|
||||
slink-id (get-in slink [:result :id])
|
||||
|
||||
out (th/command! {::th/type :delete-share-link
|
||||
::rpc/profile-id (:id editor)
|
||||
:id slink-id})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "admin CAN delete editor's share-link"
|
||||
(let [slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id editor)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "team"})
|
||||
slink-id (get-in slink [:result :id])
|
||||
|
||||
out (th/command! {::th/type :delete-share-link
|
||||
::rpc/profile-id (:id admin)
|
||||
:id slink-id})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(t/testing "owner CAN delete editor's share-link"
|
||||
(let [slink (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id editor)
|
||||
:file-id (:id file)
|
||||
:pages #{}
|
||||
:who-comment "team"
|
||||
:who-inspect "team"})
|
||||
slink-id (get-in slink [:result :id])
|
||||
|
||||
out (th/command! {::th/type :delete-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:id slink-id})]
|
||||
(t/is (nil? (:error out)))))))
|
||||
|
||||
(t/deftest share-link-fragment-access-denied
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
viewer (th/create-profile* 2 {:is-active true})
|
||||
proj-id (:default-project-id owner)
|
||||
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
|
||||
page-a (get-in file [:data :pages 0])
|
||||
|
||||
;; Create share-link
|
||||
share (th/command! {::th/type :create-share-link
|
||||
::rpc/profile-id (:id owner)
|
||||
:file-id (:id file)
|
||||
:pages #{page-a}
|
||||
:who-comment "team"
|
||||
:who-inspect "all"})
|
||||
share-id (get-in share [:result :id])]
|
||||
|
||||
(t/testing "share-link holder cannot access file fragments"
|
||||
(let [out (th/command! {::th/type :get-file-fragment
|
||||
::rpc/profile-id (:id viewer)
|
||||
:file-id (:id file)
|
||||
:fragment-id (uuid/random)
|
||||
:share-id share-id})
|
||||
err (:error out)
|
||||
edata (ex-data err)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (= :not-found (:type edata)))
|
||||
(t/is (= :object-not-found (:code edata)))))))
|
||||
@@ -608,3 +608,66 @@
|
||||
:name "Valid Font Name"}
|
||||
out (th/command! params)]
|
||||
(t/is (th/success? out))))))
|
||||
|
||||
(t/deftest create-font-variant-rejects-foreign-font-id
|
||||
;; N2-07: A user with edit permissions on their own team must not be
|
||||
;; able to create a font variant using a font-id that already belongs
|
||||
;; to another team (BOLA / CWE-639).
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof1 (th/create-profile* 1 {:is-active true})
|
||||
prof2 (th/create-profile* 2 {:is-active true})
|
||||
team1 (:default-team-id prof1)
|
||||
team2 (:default-team-id prof2)
|
||||
font-id (uuid/custom 10 999)
|
||||
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
|
||||
(io/read*))]
|
||||
|
||||
;; prof1 creates a font variant in team1 with font-id
|
||||
(let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof1)
|
||||
:team-id team1
|
||||
:font-id font-id
|
||||
:font-family "SharedFont"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; prof2 tries to create a variant using the same font-id but
|
||||
;; in team2, which must be rejected because font-id belongs to team1
|
||||
(let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024))
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof2)
|
||||
:team-id team2
|
||||
:font-id font-id
|
||||
:font-family "SharedFont"
|
||||
:font-weight 700
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}}
|
||||
out (th/command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
|
||||
|
||||
(t/deftest get-font-variants-nonexistent-file
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-font-variants
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-font-variants-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)})
|
||||
out (th/command! {::th/type :get-font-variants
|
||||
::rpc/profile-id (:id other)
|
||||
:file-id (:id file)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
@@ -50,45 +50,138 @@
|
||||
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest create-and-update-organization-invitations-audit-props
|
||||
(let [owner-id-ref (atom nil)]
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
audit-mock {:target 'app.loggers.audit/submit :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
:return (fn [_cfg method params]
|
||||
(when (= method :get-organization-summary)
|
||||
{:id (:organization-id params)
|
||||
:name "Acme"
|
||||
:owner-id @owner-id-ref
|
||||
:teams []}))}]
|
||||
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||
(let [owner (th/create-profile* 101 {:is-active true})
|
||||
invitee (th/create-profile* 102 {:is-active true})
|
||||
organization {:id (uuid/random)
|
||||
:name "Acme"
|
||||
:initials "AC"
|
||||
:logo nil
|
||||
:avatar-bg-url nil}
|
||||
_ (reset! owner-id-ref (:id owner))
|
||||
params {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:email (:email invitee)
|
||||
:organization organization}
|
||||
create-out (th/management-command! params)
|
||||
update-out (th/management-command! params)
|
||||
external-out (th/management-command! (assoc params :email "external@example.com"))
|
||||
events (mapv second (:call-args-list @audit-mock))
|
||||
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
|
||||
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
|
||||
external-event
|
||||
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
|
||||
(t/is (th/success? create-out))
|
||||
(t/is (th/success? update-out))
|
||||
(t/is (th/success? external-out))
|
||||
|
||||
(doseq [event [create-event update-event]]
|
||||
(t/is (not (contains? (:props event) :event-origin)))
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (= (:id organization)
|
||||
(get-in event [:props :organization-id])))
|
||||
(t/is (= (:email invitee)
|
||||
(get-in event [:props :member-email])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in event [:props :member-id]))))
|
||||
|
||||
(t/is (not (contains? (:props external-event) :member-id))))))))
|
||||
|
||||
(t/deftest invite-to-organization-rejects-non-owner
|
||||
(let [organization-summary-ref (atom nil)]
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
:return (fn [_cfg method _params]
|
||||
(when (= method :get-organization-summary)
|
||||
@organization-summary-ref))}]
|
||||
(let [owner (th/create-profile* 103 {:is-active true})
|
||||
attacker (th/create-profile* 104 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
organization {:id organization-id
|
||||
:name "Trusted Organization"
|
||||
:initials "TO"
|
||||
:logo nil
|
||||
:avatar-bg-url nil}
|
||||
_ (reset! organization-summary-ref
|
||||
{:id organization-id
|
||||
:name "Trusted Organization"
|
||||
:owner-id (:id owner)
|
||||
:teams []})
|
||||
out (th/management-command! {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id attacker)
|
||||
:email "victim@example.com"
|
||||
:organization organization})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(t/is (= :object-not-found (th/ex-code (:error out))))
|
||||
(t/is (not (:called? @email-mock)))))))
|
||||
|
||||
(t/deftest invite-to-organization-rejects-unknown-organization
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
audit-mock {:target 'app.loggers.audit/submit :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call :return nil}]
|
||||
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||
(let [owner (th/create-profile* 101 {:is-active true})
|
||||
invitee (th/create-profile* 102 {:is-active true})
|
||||
organization {:id (uuid/random)
|
||||
:name "Acme"
|
||||
:initials "AC"
|
||||
:logo nil
|
||||
:avatar-bg-url nil}
|
||||
params {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:email (:email invitee)
|
||||
:organization organization}
|
||||
create-out (th/management-command! params)
|
||||
update-out (th/management-command! params)
|
||||
external-out (th/management-command! (assoc params :email "external@example.com"))
|
||||
events (mapv second (:call-args-list @audit-mock))
|
||||
create-event (first (filter #(= "create-organization-invitation" (:name %)) events))
|
||||
update-event (first (filter #(= "update-organization-invitation" (:name %)) events))
|
||||
external-event
|
||||
(first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))]
|
||||
(t/is (th/success? create-out))
|
||||
(t/is (th/success? update-out))
|
||||
(t/is (th/success? external-out))
|
||||
(let [profile (th/create-profile* 105 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
out (th/management-command! {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id profile)
|
||||
:email "victim@example.com"
|
||||
:organization {:id organization-id
|
||||
:name "Fabricated Organization"
|
||||
:initials "FO"
|
||||
:logo "https://evil.example/logo.png"
|
||||
:avatar-bg-url nil}})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(t/is (= :object-not-found (th/ex-code (:error out))))
|
||||
(t/is (not (:called? @email-mock))))))
|
||||
|
||||
(doseq [event [create-event update-event]]
|
||||
(t/is (not (contains? (:props event) :event-origin)))
|
||||
(t/is (= (str (:id owner))
|
||||
(get-in event [:props :user-who-send-invitation])))
|
||||
(t/is (= (:id organization)
|
||||
(get-in event [:props :organization-id])))
|
||||
(t/is (= (:email invitee)
|
||||
(get-in event [:props :member-email])))
|
||||
(t/is (= (:id invitee)
|
||||
(get-in event [:props :member-id]))))
|
||||
|
||||
(t/is (not (contains? (:props external-event) :member-id)))))))
|
||||
(t/deftest invite-to-organization-uses-authoritative-branding
|
||||
(let [organization-summary-ref (atom nil)]
|
||||
(with-mocks [email-mock {:target 'app.email/send! :return nil}
|
||||
nitrate-mock {:target 'app.nitrate/call
|
||||
:return (fn [_cfg method _params]
|
||||
(when (= method :get-organization-summary)
|
||||
@organization-summary-ref))}]
|
||||
(binding [cf/flags (conj cf/flags :email-verification)]
|
||||
(let [owner (th/create-profile* 106 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
logo-id (uuid/random)
|
||||
_ (reset! organization-summary-ref
|
||||
{:id organization-id
|
||||
:name "Trusted Organization"
|
||||
:owner-id (:id owner)
|
||||
:logo-id logo-id
|
||||
:avatar-bg-url "https://trusted.example/avatar.svg"
|
||||
:sso-active true
|
||||
:teams []})
|
||||
out (th/management-command! {::th/type :invite-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:email "victim@example.com"
|
||||
:organization {:id organization-id
|
||||
:name "Fabricated Bank"
|
||||
:initials "FB"
|
||||
:logo "https://evil.example/logo.png"
|
||||
:avatar-bg-url "https://evil.example/avatar.svg"
|
||||
:sso-active false}})
|
||||
email-params (first (:call-args @email-mock))
|
||||
organization (:organization email-params)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= "Trusted Organization" (:name organization)))
|
||||
(t/is (= "" (:initials organization)))
|
||||
(t/is (str/ends-with? (str (:logo organization))
|
||||
(str "/assets/by-id/" logo-id)))
|
||||
(t/is (nil? (:avatar-bg-url organization)))
|
||||
(t/is (true? (:sso-active organization))))))))
|
||||
|
||||
(t/deftest get-penpot-version
|
||||
(let [out (th/management-command! {::th/type :get-penpot-version})
|
||||
@@ -175,7 +268,7 @@
|
||||
new-team (th/db-get :team {:id new-team-id})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (set/difference after-teams before-teams))))
|
||||
(t/is (= "Your Penpot" (:name new-team)))
|
||||
(t/is (= "Personal Projects" (:name new-team)))
|
||||
(t/is (true? (:is-default new-team))))))
|
||||
|
||||
(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams
|
||||
|
||||
@@ -57,6 +57,22 @@
|
||||
(t/is (not= (get-in out1 [:result :id])
|
||||
(get-in out2 [:result :id])))))
|
||||
|
||||
(t/deftest upload-tempfile-rejects-html-content-type
|
||||
;; N2-13: upload-tempfile must reject non-allowed content types
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
|
||||
_ (io/write* path "<script>alert(1)</script>")
|
||||
params {::th/type :upload-tempfile
|
||||
::rpc/profile-id (:id profile)
|
||||
:content {:filename "evil.html"
|
||||
:path path
|
||||
:mtype "text/html"
|
||||
:size 27}}
|
||||
out (th/management-command! params)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (th/ex-type (:error out))))
|
||||
(t/is (= :media-type-not-allowed (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest duplicate-file
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
|
||||
@@ -581,6 +581,41 @@
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))))
|
||||
|
||||
(t/deftest chunked-upload-other-profile-cannot-assemble
|
||||
;; assemble-chunks must scope the session lookup to the requesting
|
||||
;; profile so that a different profile cannot assemble chunks from
|
||||
;; a session they do not own (BOLA / CWE-639).
|
||||
(let [prof1 (th/create-profile* 1)
|
||||
prof2 (th/create-profile* 2)
|
||||
session-id (create-session! prof1 1)
|
||||
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
mfile {:filename "sample.jpg"
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}]
|
||||
|
||||
;; prof1 uploads a chunk into their own session
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof1)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; prof2 tries to assemble prof1's session via create-font-variant
|
||||
;; (which calls assemble-chunks without ownership check)
|
||||
(let [out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof2)
|
||||
:team-id (:default-team-id prof2)
|
||||
:font-id (uuid/next)
|
||||
:font-family "TestFont"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest chunked-upload-invalid-media-type
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
@@ -683,6 +718,24 @@
|
||||
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
|
||||
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
|
||||
|
||||
(t/deftest chunked-upload-invalid-total-chunks
|
||||
;; total-chunks must be at least 1; zero and negative values are rejected
|
||||
;; with a :validation error.
|
||||
(let [prof (th/create-profile* 1)]
|
||||
;; zero total-chunks
|
||||
(let [out (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 0})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type))))
|
||||
|
||||
;; negative total-chunks
|
||||
(let [out (th/command! {::th/type :create-upload-session
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks -1})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type))))))
|
||||
|
||||
(t/deftest chunked-upload-invalid-chunk-index
|
||||
;; Both a negative index and an index >= total-chunks must be
|
||||
;; rejected with a :validation / :invalid-chunk-index error.
|
||||
@@ -734,3 +787,98 @@
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :restriction (-> out :error ex-data :type)))
|
||||
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
|
||||
|
||||
;; --- Clone File Media Object BOLA tests ---
|
||||
|
||||
(defn- create-storage-object!
|
||||
[content content-type]
|
||||
(let [storage (:app.storage/storage th/*system*)]
|
||||
(sto/put-object! storage {::sto/content (sto/content content)
|
||||
:content-type content-type})))
|
||||
|
||||
(t/deftest clone-file-media-object-success
|
||||
(let [prof1 (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof1)
|
||||
:team-id (:default-team-id prof1)})
|
||||
file1 (th/create-file* 1 {:profile-id (:id prof1)
|
||||
:project-id (:default-project-id prof1)
|
||||
:is-shared false})
|
||||
sobj (create-storage-object! "image-content" "image/png")
|
||||
mobj (th/create-file-media-object* {:file-id (:id file1)
|
||||
:name "test-media"
|
||||
:width 100
|
||||
:height 100
|
||||
:mtype "image/png"
|
||||
:media-id (:id sobj)})
|
||||
file2 (th/create-file* 2 {:profile-id (:id prof1)
|
||||
:project-id (:default-project-id prof1)
|
||||
:is-shared false})
|
||||
params {::th/type :clone-file-media-object
|
||||
::rpc/profile-id (:id prof1)
|
||||
:file-id (:id file2)
|
||||
:is-local true
|
||||
:id (:id mobj)}
|
||||
out (th/command! params)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(let [result (:result out)]
|
||||
(t/is (= (:id file2) (:file-id result)))
|
||||
(t/is (= (:name mobj) (:name result)))
|
||||
(t/is (= (:media-id mobj) (:media-id result)))
|
||||
(t/is (uuid? (:id result)))
|
||||
(t/is (not= (:id mobj) (:id result))))))
|
||||
|
||||
(t/deftest clone-file-media-object-no-read-access
|
||||
(let [prof1 (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof1)
|
||||
:team-id (:default-team-id prof1)})
|
||||
file1 (th/create-file* 1 {:profile-id (:id prof1)
|
||||
:project-id (:default-project-id prof1)
|
||||
:is-shared false})
|
||||
sobj (create-storage-object! "private-content" "image/png")
|
||||
mobj (th/create-file-media-object* {:file-id (:id file1)
|
||||
:name "private-media"
|
||||
:width 100
|
||||
:height 100
|
||||
:mtype "image/png"
|
||||
:media-id (:id sobj)})
|
||||
|
||||
prof2 (th/create-profile* 2)
|
||||
_ (th/create-project* 2 {:profile-id (:id prof2)
|
||||
:team-id (:default-team-id prof2)})
|
||||
file2 (th/create-file* 2 {:profile-id (:id prof2)
|
||||
:project-id (:default-project-id prof2)
|
||||
:is-shared false})
|
||||
|
||||
params {::th/type :clone-file-media-object
|
||||
::rpc/profile-id (:id prof2)
|
||||
:file-id (:id file2)
|
||||
:is-local true
|
||||
:id (:id mobj)}
|
||||
out (th/command! params)]
|
||||
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= :not-found (:type error-data)))
|
||||
(t/is (= :object-not-found (:code error-data))))))
|
||||
|
||||
(t/deftest clone-file-media-object-source-not-found
|
||||
(let [prof (th/create-profile* 1)
|
||||
_ (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
params {::th/type :clone-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:id (uuid/random)}
|
||||
out (th/command! params)]
|
||||
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= :not-found (:type error-data)))
|
||||
(t/is (= :object-not-found (:code error-data))))))
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns backend-tests.rpc-nitrate-test
|
||||
(:require
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.json :as json]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
@@ -14,15 +15,17 @@
|
||||
[app.db :as-alias db]
|
||||
[app.email :as eml]
|
||||
[app.http :as-alias http]
|
||||
[app.http.errors :as http-errors]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc :as rpc]
|
||||
[app.rpc.commands.nitrate]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.helpers :as rph]
|
||||
[backend-tests.helpers :as th]
|
||||
[buddy.core.codecs :as bc]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]))
|
||||
[cuerdas.core :as str]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
@@ -86,6 +89,31 @@
|
||||
|
||||
nil)))
|
||||
|
||||
(defn- unauthorized-sso-mock
|
||||
"Creates a mock for nitrate/sso-session-authorized? that reports an active
|
||||
SSO the session does not satisfy. Pass nil to leave the organization out of
|
||||
the nitrate payload."
|
||||
[organization-id]
|
||||
(fn [_cfg _organization-id _team-id _request]
|
||||
{:authorized false
|
||||
:sso (cond-> {:active true
|
||||
:issuer "https://idp.example.com"}
|
||||
(some? organization-id)
|
||||
(assoc :organization-id organization-id))}))
|
||||
|
||||
(defn- sso-gate-error
|
||||
"Builds the SSO gate around a handler that must never be reached, and
|
||||
returns the exception it raises for `params`."
|
||||
[mdata params cfg]
|
||||
(let [handler (fn [_cfg _params] ::handler-called)
|
||||
wrapped (binding [cf/flags (conj cf/flags :admin-console)]
|
||||
(#'rpc/wrap-nitrate-sso nil handler mdata))]
|
||||
(try
|
||||
(wrapped cfg (with-meta params {::http/request {}}))
|
||||
nil
|
||||
(catch Throwable cause
|
||||
cause))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Tests
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -111,13 +139,17 @@
|
||||
(constantly "https://idp.example.com/authorize")]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:authorized true} (:result out))))))))
|
||||
;; The reason tells the client this is a permission problem, not a
|
||||
;; usable SSO session.
|
||||
(t/is (= {:authorized true :reason :no-team-access} (:result out))))))))
|
||||
|
||||
(t/deftest check-nitrate-sso-keeps-gate-for-team-member
|
||||
(let [team-owner (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id team-owner)})
|
||||
organization-id (uuid/random)
|
||||
redirect-uri "https://idp.example.com/authorize"
|
||||
redirect-options (atom nil)
|
||||
started-event (atom nil)
|
||||
params (with-meta
|
||||
{::th/type :check-nitrate-sso
|
||||
::rpc/profile-id (:id team-owner)
|
||||
@@ -131,12 +163,50 @@
|
||||
organization-id
|
||||
(:id team-owner))
|
||||
oidc/build-organization-sso-auth-redirect-uri
|
||||
(constantly redirect-uri)]
|
||||
(fn [_cfg _sso & options]
|
||||
(reset! redirect-options (apply hash-map options))
|
||||
redirect-uri)
|
||||
oidc/submit-organization-sso-auth-started-event
|
||||
(fn [_cfg _request profile-id received-organization-id]
|
||||
(reset! started-event {:profile-id profile-id
|
||||
:organization-id received-organization-id}))]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:authorized false
|
||||
:redirect-uri redirect-uri}
|
||||
(:result out))))))))
|
||||
(:result out)))
|
||||
(t/is (= #{:dest-url :organization-id} (set (keys @redirect-options))))
|
||||
(t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options))))
|
||||
(t/is (nil? (:organization-id @redirect-options)))
|
||||
(t/is (= {:profile-id (:id team-owner)
|
||||
:organization-id organization-id}
|
||||
@started-event)))))))
|
||||
|
||||
(t/deftest check-nitrate-sso-reports-redirect-failure
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
cause (ex-info "provider unavailable" {:response-status-code 503})
|
||||
reported (atom nil)
|
||||
params (with-meta
|
||||
{::th/type :check-nitrate-sso
|
||||
::rpc/profile-id (:id profile)
|
||||
:organization-id organization-id
|
||||
:url "https://penpot.example.com/#/workspace"}
|
||||
{::http/request {}})]
|
||||
(binding [cf/flags (conj cf/flags :admin-console)]
|
||||
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)
|
||||
oidc/build-organization-sso-auth-redirect-uri (fn [& _] (throw cause))
|
||||
oidc/submit-organization-sso-auth-failed-event
|
||||
(fn [_cfg _request profile-id received-organization-id received-cause]
|
||||
(reset! reported {:profile-id profile-id
|
||||
:organization-id received-organization-id
|
||||
:cause received-cause}))]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= {:profile-id (:id profile)
|
||||
:organization-id organization-id
|
||||
:cause cause}
|
||||
@reported)))))))
|
||||
|
||||
(t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner
|
||||
(let [team-owner (th/create-profile* 1 {:is-active true})
|
||||
@@ -168,6 +238,91 @@
|
||||
:redirect-uri redirect-uri}
|
||||
(:result out))))))))
|
||||
|
||||
(t/deftest check-nitrate-sso-reports-a-satisfied-gate-for-a-valid-session
|
||||
(let [team-owner (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id team-owner)})
|
||||
organization-id (uuid/random)
|
||||
params (with-meta
|
||||
{::th/type :check-nitrate-sso
|
||||
::rpc/profile-id (:id team-owner)
|
||||
:team-id (:id team)
|
||||
:url "https://penpot.example.com/#/workspace"}
|
||||
{::http/request {}})]
|
||||
(binding [cf/flags (conj cf/flags :admin-console)]
|
||||
(with-redefs [nitrate/sso-session-authorized?
|
||||
(fn [_cfg _organization-id _team-id _request]
|
||||
{:authorized true
|
||||
:sso {:active true
|
||||
:issuer "https://idp.example.com"
|
||||
:organization-id organization-id}})]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= {:authorized true :reason :sso-satisfied} (:result out))))))))
|
||||
|
||||
(t/deftest nitrate-sso-required-error-resolves-the-team-from-the-file
|
||||
(t/testing "the workspace path, where the file id arrives as :id, still reports the team"
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
organization-id (uuid/random)]
|
||||
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
|
||||
(let [data (ex-data (sso-gate-error {::rpc/id-type :file}
|
||||
{::rpc/profile-id (:id profile)
|
||||
:id (:id file)}
|
||||
th/*system*))]
|
||||
(t/is (= :authentication (:type data)))
|
||||
(t/is (= :nitrate-sso-required (:code data)))
|
||||
(t/is (= organization-id (:organization-id data)))
|
||||
(t/is (= (:default-team-id profile) (:team-id data))))))))
|
||||
|
||||
(t/deftest nitrate-sso-required-error-keeps-the-team-known-by-the-request
|
||||
(t/testing "an explicit team-id is not dropped by an explicit organization-id"
|
||||
(let [profile-id (uuid/random)
|
||||
team-id (uuid/random)
|
||||
organization-id (uuid/random)]
|
||||
;; The nitrate payload carries no organization-id here, so the one from
|
||||
;; the request params is the only one left to report.
|
||||
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
|
||||
(let [data (ex-data (sso-gate-error {}
|
||||
{::rpc/profile-id profile-id
|
||||
:team-id team-id
|
||||
:organization-id organization-id}
|
||||
{}))]
|
||||
(t/is (= organization-id (:organization-id data)))
|
||||
(t/is (= team-id (:team-id data))))))))
|
||||
|
||||
(t/deftest nitrate-sso-required-error-resolves-the-team-with-a-known-organization
|
||||
(t/testing "knowing the organization does not stop the team lookup"
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)})
|
||||
organization-id (uuid/random)]
|
||||
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)]
|
||||
(let [data (ex-data (sso-gate-error {}
|
||||
{::rpc/profile-id (:id profile)
|
||||
:organization-id organization-id
|
||||
:file-id (:id file)}
|
||||
th/*system*))]
|
||||
(t/is (= organization-id (:organization-id data)))
|
||||
(t/is (= (:default-team-id profile) (:team-id data))))))))
|
||||
|
||||
(t/deftest nitrate-sso-required-error-reaches-the-client-in-the-401-body
|
||||
(t/testing "the ids survive the http error response, not only the exception"
|
||||
(let [profile-id (uuid/random)
|
||||
team-id (uuid/random)
|
||||
organization-id (uuid/random)]
|
||||
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)]
|
||||
(let [cause (sso-gate-error {}
|
||||
{::rpc/profile-id profile-id
|
||||
:team-id team-id}
|
||||
{})
|
||||
response (http-errors/handle cause {})
|
||||
body (::yres/body response)]
|
||||
(t/is (= 401 (::yres/status response)))
|
||||
(t/is (= :nitrate-sso-required (:code body)))
|
||||
(t/is (= organization-id (:organization-id body)))
|
||||
(t/is (= team-id (:team-id body))))))))
|
||||
|
||||
(t/deftest leave-organization-happy-path-no-extra-teams
|
||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||
profile-user (th/create-profile* 2 {:is-active true})
|
||||
@@ -1013,12 +1168,68 @@
|
||||
@set-team-params))
|
||||
|
||||
(let [emails (->> @sent (map :to) set)]
|
||||
(t/is (= 2 (count @sent)))
|
||||
(t/is (= #{"member302@example.com" "external301@example.com"} emails))
|
||||
(t/is (= 1 (count @sent)))
|
||||
(t/is (= #{"member302@example.com"} emails))
|
||||
(doseq [email-params @sent]
|
||||
(t/is (= organization-name (:organization-name email-params)))
|
||||
(t/is (= eml/organization-setup-sso (::eml/factory email-params)))))))
|
||||
|
||||
(t/deftest add-team-to-organization-deletes-external-invitations-for-unregistered-users
|
||||
(let [owner (th/create-profile* 305 {:is-active true
|
||||
:fullname "Owner"
|
||||
:email "owner305@example.com"})
|
||||
member (th/create-profile* 306 {:is-active true
|
||||
:fullname "Member"
|
||||
:email "member306@example.com"})
|
||||
team (th/create-team* 305 {:profile-id (:id owner)})
|
||||
_ (th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id member)
|
||||
:role :editor})
|
||||
organization-id (uuid/random)
|
||||
organization-summary {:id organization-id
|
||||
:name "Test Org"
|
||||
:owner-id (:id owner)
|
||||
:teams []}
|
||||
organization-perms {:owner-id (:id owner)
|
||||
:permissions {:create-teams "any"
|
||||
:move-teams "always"
|
||||
:new-team-members "members"}}]
|
||||
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team)
|
||||
:org-id nil
|
||||
:email-to "unregistered@example.com"
|
||||
:created-by (:id owner)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "48h")})
|
||||
(th/db-insert! :team-invitation
|
||||
{:id (uuid/random)
|
||||
:team-id (:id team)
|
||||
:org-id nil
|
||||
:email-to "unregistered2@example.com"
|
||||
:created-by (:id owner)
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "48h")})
|
||||
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call (add-team-to-organization-nitrate-mock
|
||||
{:organization-id organization-id
|
||||
:organization-summary organization-summary
|
||||
:organization-perms organization-perms
|
||||
:owner-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:sso-active? false})
|
||||
teams/initialize-user-in-organization (fn [& _] nil)]
|
||||
(let [out (th/command! {::th/type :add-team-to-organization
|
||||
::rpc/profile-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:organization-id organization-id})]
|
||||
(t/is (th/success? out))))
|
||||
|
||||
(let [remaining (th/db-query :team-invitation {:team-id (:id team)})]
|
||||
(t/is (empty? remaining) "Both external invitations should be deleted"))))
|
||||
|
||||
(t/deftest create-team-in-organization-passes-association-to-nitrate
|
||||
(let [organization-id (uuid/random)
|
||||
team {:id (uuid/random)
|
||||
@@ -1123,3 +1334,45 @@
|
||||
::rpc/profile-id (:id profile)})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-code? (:error out) :nitrate-identity-unavailable))))))
|
||||
|
||||
(t/deftest redeem-nitrate-activation-code-used
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call (fn [_cfg method _params]
|
||||
(t/is (= :redeem-activation-code method))
|
||||
(ex/raise :type :nitrate-http-error
|
||||
:status 409
|
||||
:hint "activation code already used"))]
|
||||
(let [out (th/command! {::th/type :redeem-nitrate-activation-code
|
||||
::rpc/profile-id (:id profile)
|
||||
:activation-code "already-used-code"})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-code? (:error out) :used-activation-code))))))
|
||||
|
||||
(t/deftest redeem-nitrate-activation-code-expired
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call (fn [_cfg method _params]
|
||||
(t/is (= :redeem-activation-code method))
|
||||
(ex/raise :type :nitrate-http-error
|
||||
:status 410
|
||||
:hint "activation code expired"))]
|
||||
(let [out (th/command! {::th/type :redeem-nitrate-activation-code
|
||||
::rpc/profile-id (:id profile)
|
||||
:activation-code "expired-code"})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-code? (:error out) :expired-activation-code))))))
|
||||
|
||||
(t/deftest redeem-nitrate-activation-code-invalid
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call (fn [_cfg method _params]
|
||||
(t/is (= :redeem-activation-code method))
|
||||
(ex/raise :type :nitrate-http-error
|
||||
:status 422
|
||||
:hint "invalid activation code"))]
|
||||
(let [out (th/command! {::th/type :redeem-nitrate-activation-code
|
||||
::rpc/profile-id (:id profile)
|
||||
:activation-code "invalid-code"})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-code? (:error out) :invalid-activation-code))))))
|
||||
@@ -50,7 +50,16 @@
|
||||
:organization (organization-data organization-id organization-owner-id)}
|
||||
{:id (:team-id params)
|
||||
:is-your-penpot false
|
||||
:organization nil})))]
|
||||
:organization nil})
|
||||
|
||||
:get-teams-organizations
|
||||
(->> (:team-ids params)
|
||||
(keep (fn [candidate-team-id]
|
||||
(when (= team-id candidate-team-id)
|
||||
{:id team-id
|
||||
:is-your-penpot false
|
||||
:organization (organization-data organization-id organization-owner-id)})))
|
||||
vec)))]
|
||||
(f)))
|
||||
|
||||
(defn- with-captured-messages
|
||||
|
||||
@@ -125,6 +125,17 @@
|
||||
(t/is (= "en" (:lang result)))
|
||||
(t/is (= "dark" (:theme result))))))
|
||||
|
||||
(t/testing "update profile preserves omitted optional fields"
|
||||
(let [data {::th/type :update-profile
|
||||
::rpc/profile-id (:id profile)
|
||||
:fullname "Updated Name"}
|
||||
out (th/command! data)]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= "Updated Name" (get-in out [:result :fullname])))
|
||||
(t/is (= "en" (get-in out [:result :lang])))
|
||||
(t/is (= "dark" (get-in out [:result :theme])))))
|
||||
|
||||
(t/testing "update photo"
|
||||
(let [data {::th/type :update-profile-photo
|
||||
::rpc/profile-id (:id profile)
|
||||
@@ -388,6 +399,31 @@
|
||||
(let [result (th/run-task! :objects-gc {:min-age 0})]
|
||||
(t/is (= 10 (:processed result))))))
|
||||
|
||||
(t/deftest profile-deletion-invalidates-all-sessions
|
||||
(let [prof (th/create-profile* 1)
|
||||
|
||||
;; Insert 3 sessions for this profile directly into the database
|
||||
session-ids (doall
|
||||
(for [i (range 3)]
|
||||
(let [sid (uuid/random)]
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)"
|
||||
sid (:id prof) (str "user-agent-" i)])
|
||||
sid)))]
|
||||
|
||||
;; Verify sessions exist
|
||||
(let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
|
||||
(t/is (= 3 count-before)))
|
||||
|
||||
;; Request profile to be deleted
|
||||
(let [params {::th/type :delete-profile
|
||||
::rpc/profile-id (:id prof)}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; Verify ALL sessions were invalidated (not just one)
|
||||
(let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
|
||||
(t/is (= 0 count-after)))))
|
||||
|
||||
|
||||
(t/deftest email-blacklist-1
|
||||
(t/is (false? (email.blacklist/enabled? th/*system*)))
|
||||
|
||||
@@ -241,3 +241,24 @@
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (= (:type error-data) :not-found))))))
|
||||
|
||||
(t/deftest get-project-nonexistent
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
out (th/command! {::th/type :get-project
|
||||
::rpc/profile-id (:id prof)
|
||||
:id (uuid/random)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
|
||||
(t/deftest get-project-no-permission
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
other (th/create-profile* 2 {:is-active true})
|
||||
proj (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:default-team-id owner)})
|
||||
out (th/command! {::th/type :get-project
|
||||
::rpc/profile-id (:id other)
|
||||
:id (:id proj)})
|
||||
err (:error out)]
|
||||
(t/is (th/ex-info? err))
|
||||
(t/is (th/ex-of-type? err :not-found))))
|
||||
@@ -338,3 +338,137 @@
|
||||
|
||||
(check-ok! 4)
|
||||
(check-ko! 5))))
|
||||
|
||||
(t/deftest media-storage-bytes-per-team-quote
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-media-storage-bytes-per-team 1000})}]
|
||||
|
||||
(let [profile-1 (th/create-profile* 1)
|
||||
profile-2 (th/create-profile* 2)
|
||||
team-id (:default-team-id profile-1)
|
||||
data {::quotes/id ::quotes/media-storage-bytes-per-team
|
||||
::quotes/profile-id (:id profile-1)
|
||||
::quotes/team-id team-id
|
||||
::quotes/incr 500}
|
||||
|
||||
check-ok! (fn [msg]
|
||||
(quotes/check! th/*system* data)
|
||||
(t/is (true? true) msg))
|
||||
check-ko! (fn [msg]
|
||||
(try
|
||||
(quotes/check! th/*system* data)
|
||||
(t/is false (str msg " — expected exception but none thrown"))
|
||||
(catch Exception e
|
||||
(let [ed (ex-data e)]
|
||||
(t/is (= :restriction (:type ed)))
|
||||
(t/is (= :max-quote-reached (:code ed)))
|
||||
(t/is (= "media-storage-bytes-per-team" (:target ed)))))))]
|
||||
|
||||
;; Under default limit (1000) with incr=500 and no existing storage — ok
|
||||
(check-ok! "first check under limit")
|
||||
|
||||
;; Insert a quote row for another profile on the same team — does not help
|
||||
(th/db-insert! :usage-quote
|
||||
{:profile-id (:id profile-2)
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 100})
|
||||
|
||||
;; Insert a team+profile quote that is still too low
|
||||
(th/db-insert! :usage-quote
|
||||
{:team-id team-id
|
||||
:profile-id (:id profile-2)
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 200})
|
||||
|
||||
;; Insert a team-level quote (no profile) that is still too low
|
||||
(th/db-insert! :usage-quote
|
||||
{:team-id team-id
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 400})
|
||||
|
||||
;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked
|
||||
(check-ko! "blocked by team-level quote")
|
||||
|
||||
;; Insert a team+profile quote that allows it
|
||||
(th/db-insert! :usage-quote
|
||||
{:team-id team-id
|
||||
:profile-id (:id profile-1)
|
||||
:target "media-storage-bytes-per-team"
|
||||
:quote 1000})
|
||||
|
||||
;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok
|
||||
(check-ok! "allowed by team+profile quote"))))
|
||||
|
||||
(t/deftest media-storage-bytes-quote-deduped
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-media-storage-bytes-per-team 1100})}]
|
||||
|
||||
(let [prof (th/create-profile* 1)
|
||||
team-id (:default-team-id prof)
|
||||
proj (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id team-id})
|
||||
file1 (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:id proj)
|
||||
:is-shared false})
|
||||
file2 (th/create-file* 2 {:profile-id (:id prof)
|
||||
:project-id (:id proj)
|
||||
:is-shared false})
|
||||
|
||||
;; One physical storage object of 500 bytes
|
||||
so-id (uuid/random)
|
||||
_ (th/db-insert! :storage-object {:id so-id
|
||||
:size 500
|
||||
:backend "test"})
|
||||
|
||||
;; Two file_media_object rows pointing at the SAME storage object
|
||||
;; (simulates the deduplication path: same content uploaded twice)
|
||||
_ (th/create-file-media-object*
|
||||
{:file-id (:id file1) :media-id so-id
|
||||
:name "icon" :mtype "image/svg+xml"})
|
||||
_ (th/create-file-media-object*
|
||||
{:file-id (:id file2) :media-id so-id
|
||||
:name "icon" :mtype "image/svg+xml"})
|
||||
|
||||
data {::quotes/id ::quotes/media-storage-bytes-per-team
|
||||
::quotes/profile-id (:id prof)
|
||||
::quotes/team-id team-id
|
||||
::quotes/incr 200}]
|
||||
|
||||
;; Physical size is 500. With UNION (correct), total=500, 500+200=700 ≤ 1100 → ok.
|
||||
;; With UNION ALL (buggy), total=1000, 1000+200=1200 > 1100 → rejected.
|
||||
(quotes/check! th/*system* data)
|
||||
(t/is (true? true) "deduped storage counted once, under quota"))))
|
||||
|
||||
(t/deftest media-upload-enforces-storage-quote
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/config-get-mock
|
||||
{:quotes-media-storage-bytes-per-team 100})}]
|
||||
|
||||
(let [prof (th/create-profile* 1)
|
||||
proj (th/create-project* 1 {:profile-id (:id prof)
|
||||
:team-id (:default-team-id prof)})
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:id proj)
|
||||
:is-shared false})
|
||||
mfile {:filename "sample.jpg"
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
|
||||
params {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out (th/command! params)]
|
||||
|
||||
;; 312043 bytes > 100 byte limit → should be rejected
|
||||
(t/is (not (th/success? out)))
|
||||
(let [error (:error out)]
|
||||
(t/is (= :restriction (th/ex-type error)))
|
||||
(t/is (= :max-quote-reached (th/ex-code error)))
|
||||
(t/is (= "media-storage-bytes-per-team" (:target (ex-data error))))))))
|
||||
@@ -0,0 +1,28 @@
|
||||
;; 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 Espana SL
|
||||
|
||||
(ns backend-tests.rpc-rlimit-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.redis :as rds]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest bucket-reset-supports-fractional-milliseconds
|
||||
(let [now (ct/inst 0)
|
||||
limit {::rlimit/name :test
|
||||
::rlimit/strategy :bucket
|
||||
::rlimit/key "test"
|
||||
::rlimit/method "main.test"
|
||||
::rlimit/capacity 5
|
||||
::rlimit/rate 3
|
||||
::rlimit/interval (ct/duration 1000)
|
||||
::rlimit/params [1 3 5]
|
||||
::rlimit/opts "5/3/1s"}]
|
||||
(with-redefs [rds/eval (fn [_ _] [true 4])]
|
||||
(let [result (rlimit/process-limit nil "profile" now limit)]
|
||||
(t/is (= (ct/inst 334)
|
||||
(:app.rpc.rlimit.result/reset result)))))))
|
||||
@@ -357,6 +357,28 @@
|
||||
(t/is (= (:id profile2) (:member-id claims))))))))
|
||||
|
||||
|
||||
(t/deftest get-team-invitation-token-requires-edition-permissions
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
profile2 (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||
pool (:app.db/pool th/*system*)]
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id profile2)
|
||||
:role :viewer})
|
||||
(db/insert! pool :team-invitation
|
||||
{:team-id (:id team)
|
||||
:email-to "victim@example.com"
|
||||
:role "editor"
|
||||
:valid-until (ct/in-future "48h")})
|
||||
(let [data {::th/type :get-team-invitation-token
|
||||
::rpc/profile-id (:id profile2)
|
||||
:team-id (:id team)
|
||||
:email "victim@example.com"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type))))))
|
||||
|
||||
|
||||
(t/deftest accept-invitation-tokens
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
profile2 (th/create-profile* 2 {:is-active true})
|
||||
@@ -719,6 +741,67 @@
|
||||
(t/is (not= (:default-team-id profile1) (:id item1))))))
|
||||
|
||||
|
||||
(t/deftest get-teams-fetches-organizations-in-one-batch
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
organization-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
plain-team (th/create-team* 2 {:profile-id (:id profile)})
|
||||
expired-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||
organization-id (uuid/random)
|
||||
calls (atom [])
|
||||
organization {:id organization-id
|
||||
:name "Acme"
|
||||
:slug "acme"
|
||||
:owner-id (:id profile)
|
||||
:avatar-bg-url "https://example.com/avatar.svg"}
|
||||
nitrate-call (fn [_cfg method params]
|
||||
(swap! calls conj [method params])
|
||||
[{:id (:id organization-team)
|
||||
:is-your-penpot false
|
||||
:organization organization}
|
||||
{:id (:id expired-team)
|
||||
:is-your-penpot false
|
||||
:organization (assoc organization :expired-license true)}])
|
||||
params {::th/type :get-teams
|
||||
::rpc/profile-id (:id profile)}]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call nitrate-call]
|
||||
(let [out (th/command! params)
|
||||
teams (:result out)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count @calls)))
|
||||
(t/is (= :get-teams-organizations (ffirst @calls)))
|
||||
(t/is (= #{(:default-team-id profile)
|
||||
(:id organization-team)
|
||||
(:id plain-team)
|
||||
(:id expired-team)}
|
||||
(-> @calls first second :team-ids set)))
|
||||
(t/is (= #{(:default-team-id profile)
|
||||
(:id organization-team)
|
||||
(:id plain-team)}
|
||||
(into #{} (map :id) teams)))
|
||||
(t/is (= organization
|
||||
(->> teams
|
||||
(filter #(= (:id organization-team) (:id %)))
|
||||
first
|
||||
:organization)))))))
|
||||
|
||||
|
||||
(t/deftest get-teams-rejects-invalid-organization-batch-response
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
calls (atom [])
|
||||
params {::th/type :get-teams
|
||||
::rpc/profile-id (:id profile)}]
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call (fn [_cfg method call-params]
|
||||
(swap! calls conj [method call-params])
|
||||
nil)]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :nitrate-unavailable (th/ex-type (:error out))))
|
||||
(t/is (= 1 (count @calls)))
|
||||
(t/is (= :get-teams-organizations (ffirst @calls)))))))
|
||||
|
||||
|
||||
(t/deftest team-deletion-1
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||
@@ -1096,3 +1179,152 @@
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
(t/deftest create-team-in-organization-regression
|
||||
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
||||
(let [owner (th/create-profile* 401 {:is-active true})
|
||||
non-member (th/create-profile* 402 {:is-active true})
|
||||
organization-id (uuid/random)
|
||||
params {::th/type :create-team
|
||||
::rpc/profile-id (:id owner)
|
||||
:name "Test Team"
|
||||
:organization-id organization-id}
|
||||
|
||||
nitrate-call-fn
|
||||
(fn [_cfg method p]
|
||||
(case method
|
||||
:get-organization-membership
|
||||
(if (= (:profile-id p) (:id non-member))
|
||||
{:organization-id organization-id :is-member false}
|
||||
{:organization-id organization-id :is-member true})
|
||||
|
||||
:get-organization-permissions
|
||||
{:owner-id (:id owner)
|
||||
:permissions {:create-teams "any"}}
|
||||
|
||||
:set-team-organization
|
||||
(let [team-id (:team-id p)]
|
||||
{:id team-id
|
||||
:name "Test Team"
|
||||
:organization-id organization-id
|
||||
:default-project-id (uuid/random)})
|
||||
|
||||
nil))]
|
||||
|
||||
;; Non-member should be denied with :user-doesnt-belong-organization
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call nitrate-call-fn]
|
||||
(let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))]
|
||||
(t/is (not (th/success? out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :user-doesnt-belong-organization (:code edata))))))
|
||||
|
||||
;; Authorized member should succeed
|
||||
(th/reset-mock! audit-mock)
|
||||
(with-redefs [cf/flags (conj cf/flags :admin-console)
|
||||
nitrate/call nitrate-call-fn]
|
||||
(let [out (th/command! params)]
|
||||
(t/is (th/success? out))
|
||||
(let [team (:result out)]
|
||||
(t/is (uuid? (:id team)))
|
||||
(t/is (= "Test Team" (:name team)))))))))
|
||||
|
||||
;; --- T7-F-01: Role ceiling in team invitations ---
|
||||
|
||||
(t/deftest admin-cannot-create-invitation-with-owner-role
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
admin (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
|
||||
;; Add admin as team member with :admin role
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id admin)
|
||||
:role :admin})
|
||||
|
||||
;; Admin tries to create invitation with :owner role (emails+role format)
|
||||
;; This should FAIL with :cant-promote-to-owner
|
||||
(let [data {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id admin)
|
||||
:team-id (:id team)
|
||||
:role :owner
|
||||
:emails ["invitee@example.com"]}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))
|
||||
(t/is (= 0 (:call-count @mock)))))))
|
||||
|
||||
(t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
admin (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
|
||||
;; Add admin as team member with :admin role
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id admin)
|
||||
:role :admin})
|
||||
|
||||
;; Admin tries to create invitation with :owner role (invitations format)
|
||||
;; This should FAIL with :cant-promote-to-owner
|
||||
(let [data {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id admin)
|
||||
:team-id (:id team)
|
||||
:invitations [{:email "invitee@example.com" :role :owner}]}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))
|
||||
(t/is (= 0 (:call-count @mock)))))))
|
||||
|
||||
(t/deftest admin-cannot-update-invitation-role-to-owner
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
admin (th/create-profile* 2 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
|
||||
;; Add admin as team member with :admin role
|
||||
(th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id admin)
|
||||
:role :admin})
|
||||
|
||||
;; Owner creates an invitation with :editor role
|
||||
(let [data {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:role :editor
|
||||
:emails ["invitee@example.com"]}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))
|
||||
|
||||
(th/reset-mock! mock)
|
||||
|
||||
;; Admin tries to update invitation role to :owner
|
||||
;; This should FAIL with :cant-promote-to-owner
|
||||
(let [data {::th/type :update-team-invitation-role
|
||||
::rpc/profile-id (:id admin)
|
||||
:team-id (:id team)
|
||||
:email "invitee@example.com"
|
||||
:role :owner}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :cant-promote-to-owner))))))
|
||||
|
||||
(t/deftest owner-can-create-invitation-with-owner-role
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})]
|
||||
|
||||
;; Owner creates invitation with :owner role
|
||||
;; This should SUCCEED (owner has full privileges)
|
||||
(let [data {::th/type :create-team-invitations
|
||||
::rpc/profile-id (:id owner)
|
||||
:team-id (:id team)
|
||||
:role :owner
|
||||
:emails ["invitee@example.com"]}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (:call-count @mock)))))))
|
||||
@@ -710,6 +710,19 @@
|
||||
(t/is (not (contains? (:props result) :route)))
|
||||
(t/is (not (contains? (:props result) :label)))))
|
||||
|
||||
(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason
|
||||
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
|
||||
organization-id (uuid/next)
|
||||
result (ftp {:source "backend"
|
||||
:name "organization-sso-auth-failed"
|
||||
:type "action"
|
||||
:props {:organization-id organization-id
|
||||
:failure-reason "access-denied"
|
||||
:unsafe-label "should-be-stripped"}})]
|
||||
(t/is (= {:organization-id organization-id
|
||||
:failure-reason "access-denied"}
|
||||
(:props result)))))
|
||||
|
||||
(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids
|
||||
;; Frontend navigate events keep specific routing keys: :route,
|
||||
;; :file-id, :team-id, :page-id. These ids are strings because
|
||||
|
||||
@@ -13,11 +13,25 @@
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest validate-url-allows-public-https
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
|
||||
(let [original ssrf/resolve-host]
|
||||
(with-redefs [ssrf/resolve-host
|
||||
(fn [hostname]
|
||||
(if (= hostname "example.com")
|
||||
(into-array java.net.InetAddress
|
||||
[(java.net.InetAddress/getByName "93.184.216.34")])
|
||||
(original hostname)))]
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
|
||||
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))))
|
||||
|
||||
(t/deftest validate-url-allows-public-http
|
||||
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
|
||||
(let [original ssrf/resolve-host]
|
||||
(with-redefs [ssrf/resolve-host
|
||||
(fn [hostname]
|
||||
(if (= hostname "example.com")
|
||||
(into-array java.net.InetAddress
|
||||
[(java.net.InetAddress/getByName "93.184.216.34")])
|
||||
(original hostname)))]
|
||||
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))))
|
||||
|
||||
(t/deftest validate-url-blocks-disallowed-schemes
|
||||
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
|
||||
|
||||
@@ -1192,6 +1192,15 @@
|
||||
str/trim)
|
||||
""))
|
||||
|
||||
(defn escape-markdown
|
||||
"Escapes Markdown special characters by prefixing them with backslash.
|
||||
Intended for user-controlled values embedded in Markdown messages
|
||||
(e.g. Mattermost notifications)."
|
||||
[s]
|
||||
(if s
|
||||
(str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c)))
|
||||
""))
|
||||
|
||||
(defn get-initials
|
||||
"Returns up to two uppercase initials extracted from a string.
|
||||
Non-letter prefixes in each token are ignored."
|
||||
|
||||
@@ -1978,6 +1978,26 @@
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0026-fix-svg-raw-shapes-uuids"
|
||||
;; Before the svg-raw schema declared :shapes as a vector of uuid,
|
||||
;; the JSON decoder had no type information for those child ids and
|
||||
;; left them as plain strings on any round trip, so they got
|
||||
;; persisted as strings. Once the schema was tightened, such files
|
||||
;; fail schema validation; this migration parses the strings back
|
||||
;; into uuid instances.
|
||||
[data _]
|
||||
(letfn [(update-object [object]
|
||||
(cond-> object
|
||||
(cfh/svg-raw-shape? object)
|
||||
(d/update-when :shapes #(mapv uuid/coerce %))))
|
||||
|
||||
(update-container [container]
|
||||
(d/update-when container :objects d/update-vals update-object))]
|
||||
|
||||
(-> data
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(def available-migrations
|
||||
(into (d/ordered-set)
|
||||
["legacy-2"
|
||||
@@ -2060,4 +2080,5 @@
|
||||
"0022-normalize-component-root-and-resync"
|
||||
"0023-repair-token-themes-with-inexistent-sets"
|
||||
"0024b-fix-stroke-cap-placement"
|
||||
"0025-repair-empty-text-content"]))
|
||||
"0025-repair-empty-text-content"
|
||||
"0026-fix-svg-raw-shapes-uuids"]))
|
||||
@@ -147,7 +147,6 @@
|
||||
:render-switch
|
||||
:hide-release-modal
|
||||
:subscriptions
|
||||
:subscriptions-old
|
||||
:inspect-styles
|
||||
;; Enable performance logs in devconsole (disabled by default)
|
||||
:perf-logs
|
||||
|
||||
@@ -2345,7 +2345,12 @@
|
||||
updated-sync-groups (into #{}
|
||||
(keep #(ctk/resolve-sync-group (:type previous-shape) %))
|
||||
updated-attrs)
|
||||
new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups)
|
||||
text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure}
|
||||
new-touched (set/union (or (:touched current-shape) #{})
|
||||
updated-sync-groups
|
||||
(when (contains? updated-sync-groups :content-group)
|
||||
(set/intersection (or (:touched previous-shape) #{})
|
||||
text-sub-touched)))
|
||||
roperations (into [{:type :set-touched :touched new-touched}] roperations)
|
||||
uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)]
|
||||
(cond-> changes
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"image/gif"
|
||||
"image/svg+xml"})
|
||||
|
||||
(def tempfile-types
|
||||
(conj image-types "application/pdf" "application/zip"))
|
||||
|
||||
(defn format->extension
|
||||
[format]
|
||||
(case format
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.shape-tree :as ctst]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.types.tokens-lib :refer [schema:tokens-lib]]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.common.types.tokens-status :as ctos]
|
||||
[app.common.types.typographies-list :as ctyl]
|
||||
[app.common.types.typography :as cty]
|
||||
[app.common.uuid :as uuid]
|
||||
@@ -86,7 +87,15 @@
|
||||
[:components {:optional true} schema:components]
|
||||
[:typographies {:optional true} schema:typographies]
|
||||
[:plugin-data {:optional true} schema:plugin-data]
|
||||
[:tokens-lib {:optional true} schema:tokens-lib]])
|
||||
[:tokens-source {:optional true} ::sm/uuid] ;; Forward-compat: UUID of external library containing tokens-lib (full support in follow-up PR)
|
||||
[:tokens-lib {:optional true} ctob/schema:tokens-lib]
|
||||
[:tokens-status {:optional true} ctos/schema:tokens-status]])
|
||||
|
||||
(def schema:file-metadata
|
||||
[:map {:title "Metadata"}
|
||||
[:storage-ref-id {:optional true} ::sm/uuid]
|
||||
[:generated-by {:optional true} :string]
|
||||
[:referer {:optional true} :string]])
|
||||
|
||||
(def schema:file
|
||||
"A schema for validate a file data structure; data is optional
|
||||
@@ -106,6 +115,7 @@
|
||||
[:data {:optional true} schema:data]
|
||||
[:version :int]
|
||||
[:features ::cfeat/features]
|
||||
[:metadata {:optional true} schema:file-metadata]
|
||||
[:migrations {:optional true}
|
||||
[::sm/set {:ordered true} :string]]])
|
||||
|
||||
@@ -123,6 +133,9 @@
|
||||
(def check-file-media
|
||||
(sm/check-fn schema:media))
|
||||
|
||||
(def decode-file-metadata
|
||||
(sm/decoder schema:file-metadata sm/json-transformer))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INITIALIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -306,6 +319,7 @@
|
||||
(update-objects-tree container f)))))
|
||||
|
||||
;; Asset helpers
|
||||
|
||||
(defn find-component-file
|
||||
[file libraries component-file]
|
||||
(if (and (some? file) (= component-file (:id file)))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.flags :as flags]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[clojure.set :as set]
|
||||
@@ -217,7 +218,10 @@
|
||||
attributes or other things that may be attached).
|
||||
- Consider nil values, empty strings or empty lists all equal.
|
||||
- Normalize numeric values (legacy) into strings.
|
||||
- No value is equal than the default value."
|
||||
- No value is equal than the default value.
|
||||
- Numeric attrs (e.g. line-height) compare with float tolerance so
|
||||
editor/WASM round-trips like \"1.3333333333333333\" vs \"1.33333\"
|
||||
do not count as a real style change (avoids detaching tokens)."
|
||||
[key value1 value2]
|
||||
(when (text-node-attr? key)
|
||||
(let [default-value (get default-text-attrs key)
|
||||
@@ -229,7 +233,16 @@
|
||||
$)))
|
||||
value1' (normalize-value value1)
|
||||
value2' (normalize-value value2)]
|
||||
(not= value1' value2'))))
|
||||
(cond
|
||||
(= value1' value2')
|
||||
false
|
||||
|
||||
:else
|
||||
(let [n1 (when (string? value1') (d/parse-double value1'))
|
||||
n2 (when (string? value2') (d/parse-double value2'))]
|
||||
(if (and (some? n1) (some? n2))
|
||||
(not (mth/close? n1 n2))
|
||||
true))))))
|
||||
|
||||
(defn- compare-text-content
|
||||
"Given two content text structures, conformed by maps and vectors,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
;; 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.common.types.tokens-status
|
||||
(:require
|
||||
#?(:clj [app.common.fressian :as fres])
|
||||
#?(:clj [clojure.data.json :as c.json])
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.transit :as t]
|
||||
[clojure.core.protocols :as cp]
|
||||
[clojure.datafy :refer [datafy]]
|
||||
[clojure.pprint :as pp]))
|
||||
|
||||
;; TokensStatus datatype contains the activation status of the themes and sets
|
||||
;; in a tokens library.
|
||||
|
||||
(defprotocol ITokensStatus
|
||||
(get-active-theme-ids [_] "Return a clojure set of active theme ids")
|
||||
(get-active-set-ids [_] "Return a clojure set of active set ids")
|
||||
(theme-active? [_ theme-id] "Check if a theme is active")
|
||||
(set-active? [_ set-id] "Check if a set is active")
|
||||
(set-tokens-status [_ theme-ids set-ids] "Set the activation status of the themes and sets"))
|
||||
|
||||
(deftype TokensStatus [active-theme-ids active-set-ids]
|
||||
cp/Datafiable
|
||||
(datafy [_]
|
||||
{:active-theme-ids active-theme-ids
|
||||
:active-set-ids active-set-ids})
|
||||
|
||||
#?@(:clj
|
||||
[c.json/JSONWriter
|
||||
(-write [this writter options]
|
||||
(c.json/-write (datafy this) writter options))])
|
||||
|
||||
ITokensStatus
|
||||
(get-active-theme-ids [_]
|
||||
active-theme-ids)
|
||||
|
||||
(get-active-set-ids [_]
|
||||
active-set-ids)
|
||||
|
||||
(theme-active? [_ theme-id]
|
||||
(assert (uuid? theme-id))
|
||||
(contains? active-theme-ids theme-id))
|
||||
|
||||
(set-active? [_ set-id]
|
||||
(assert (uuid? set-id))
|
||||
(contains? active-set-ids set-id))
|
||||
|
||||
(set-tokens-status [_ theme-ids set-ids]
|
||||
(assert (set? theme-ids))
|
||||
(assert (set? set-ids))
|
||||
(TokensStatus. theme-ids set-ids)))
|
||||
|
||||
;; === Helper & Predicate ===
|
||||
|
||||
(defn map->TokensStatus
|
||||
[{:keys [active-theme-ids active-set-ids]}]
|
||||
(TokensStatus. active-theme-ids active-set-ids))
|
||||
|
||||
(defn tokens-status?
|
||||
[o]
|
||||
(instance? TokensStatus o))
|
||||
|
||||
;; === Schemas, Check functions & Constructor ===
|
||||
|
||||
(declare make-tokens-status)
|
||||
|
||||
(def schema:tokens-status-attrs
|
||||
[:map {:title "TokensStatus"}
|
||||
[:active-theme-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]
|
||||
[:active-set-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]])
|
||||
|
||||
(def schema:tokens-status
|
||||
[:and {:gen/gen (->> (sg/generator schema:tokens-status-attrs)
|
||||
(sg/fmap #(make-tokens-status %)))}
|
||||
[:fn tokens-status?]])
|
||||
|
||||
(def ^:private check-tokens-status-attrs
|
||||
(sm/check-fn schema:tokens-status-attrs
|
||||
:hint "expected valid params for tokens-status"))
|
||||
|
||||
(def check-tokens-status
|
||||
(sm/check-fn schema:tokens-status
|
||||
:hint "expected valid tokens-status"))
|
||||
|
||||
(defn make-tokens-status
|
||||
[& {:as attrs}]
|
||||
(-> attrs
|
||||
(update :active-theme-ids #(or % #{}))
|
||||
(update :active-set-ids #(or % #{}))
|
||||
(check-tokens-status-attrs)
|
||||
(map->TokensStatus)))
|
||||
|
||||
;; === Pretty-print for debugging ===
|
||||
|
||||
(defmethod pp/simple-dispatch TokensStatus [^TokensStatus obj]
|
||||
(.write *out* "#penpot/tokens-status ")
|
||||
(pp/pprint-newline :miser)
|
||||
(pp/pprint (datafy obj)))
|
||||
|
||||
#?(:clj
|
||||
(do
|
||||
(defmethod print-method TokensStatus
|
||||
[^TokensStatus this ^java.io.Writer w]
|
||||
(.write w "#penpot/tokens-status ")
|
||||
(print-method (datafy this) w))
|
||||
|
||||
(defmethod print-dup TokensStatus
|
||||
[^TokensStatus this ^java.io.Writer w]
|
||||
(print-method this w)))
|
||||
|
||||
:cljs
|
||||
(extend-type TokensStatus
|
||||
cljs.core/IPrintWithWriter
|
||||
(-pr-writer [this writer opts]
|
||||
(-write writer "#penpot/tokens-status ")
|
||||
(-pr-writer (datafy this) writer opts))
|
||||
|
||||
cljs.core/IEncodeJS
|
||||
(-clj->js [this]
|
||||
(clj->js (datafy this)))))
|
||||
|
||||
;; === Transit serialization ===
|
||||
|
||||
(t/add-handlers!
|
||||
{:id "penpot/tokens-status"
|
||||
:class TokensStatus
|
||||
:wfn datafy
|
||||
:rfn #(make-tokens-status %)})
|
||||
|
||||
;; === Fressian serialization ===
|
||||
|
||||
#?(:clj
|
||||
(fres/add-handlers!
|
||||
{:name "penpot/tokens-status/v1"
|
||||
:class TokensStatus
|
||||
:wfn (fn [n w o]
|
||||
(fres/write-tag! w n 1)
|
||||
(fres/write-object! w (datafy o)))
|
||||
:rfn (fn [r]
|
||||
(let [obj (fres/read-object! r)]
|
||||
(make-tokens-status obj)))}))
|
||||
@@ -67,6 +67,36 @@
|
||||
path
|
||||
(str path "/")))))
|
||||
|
||||
(defn- update-query-params
|
||||
"Apply `f` to the query-params map of `url`, returning the updated URL string.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url f]
|
||||
(let [transform (fn [parsed]
|
||||
(update parsed :query
|
||||
(fn [q]
|
||||
(-> (query-string->map (or q ""))
|
||||
f
|
||||
map->query-string))))
|
||||
parsed (uri url)
|
||||
fragment (:fragment parsed)]
|
||||
(if (str/blank? fragment)
|
||||
(str (transform parsed))
|
||||
(-> parsed
|
||||
(assoc :fragment (str (transform (parse fragment))))
|
||||
str))))
|
||||
|
||||
(defn append-query-param
|
||||
"Return a new URL string with the given query parameter added or replaced.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url key value]
|
||||
(update-query-params url #(assoc % key value)))
|
||||
|
||||
(defn remove-query-param
|
||||
"Return a new URL string with the given query parameter removed.
|
||||
Handles both plain query strings and fragment-based (hash) URLs."
|
||||
[url key]
|
||||
(update-query-params url #(dissoc % key)))
|
||||
|
||||
#?(:clj
|
||||
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
|
||||
(.write writer "#")
|
||||
|
||||
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user