Compare commits

..
Author SHA1 Message Date
Andrés Moya 8d8f0bb125 🔧 Add tests and validations to variant functions 2026-09-09 17:18:20 +02:00
Andrés Moya ea8a7dd3d8 💄 Rename props -> properties 2026-09-09 15:14:23 +02:00
Andrés Moya 65b578a549 🔧 Validate changes immediately after processing them 2026-09-09 15:14:23 +02:00
281 changed files with 4174 additions and 11022 deletions

No files matched your search

+2 -20
View File
@@ -20,17 +20,6 @@ Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
is the authoritative source for the commit message format, the emoji menu,
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
## Iron Rules (non-negotiable)
1. **Wrap every body line at 76 characters or fewer.** Count characters, do
not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and
lines carrying a URL. This is the rule agents skip most often.
2. **Subject ≤70 chars**, imperative, capitalized, no trailing period.
3. **Blank line between subject and body.**
4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically
checks rules 13. A non-zero exit is a hard blocker: fix the message and
re-commit. Never report the commit as done with a failing checker.
## Workflow
1. **Stage the files** specified by the calling context. Do not ask for
@@ -40,18 +29,12 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
that does not match the stated intent, **STOP** and tell the user before
committing.
3. Draft the message following the format in the memory doc, wrapping the body
at 76 characters per line, and run:
at 72 characters per line, and run:
```bash
git commit -m "<subject>" -m "<body>"
```
(or `git commit -F -` if the body has unusual characters).
4. **Verify the message with the checker**:
```bash
./scripts/check-commit
```
If it fails, amend the message (`git commit --amend`) until it passes. Do
not finish with a failing checker.
5. The `AI-assisted-by` trailer value is provided by the calling context — use
4. The `AI-assisted-by` trailer value is provided by the calling context — use
it verbatim.
## Constraints
@@ -62,4 +45,3 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
- Do not amend a commit you did not create in this session, unless explicitly asked.
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
- Do not add untracked files that were not created in this session.
- Do not skip the `scripts/check-commit` verification step (Iron Rule 4).
+237 -62
View File
@@ -1,11 +1,13 @@
---
name: planner
description: Read-only planning and architecture analysis — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan, suggested save path and the next steps.
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
---
# Planner
Produce a plan that another engineer or agent can execute without guessing.
Read-only senior software architect role for Penpot. Produces structured
implementation plans with task breakdowns that engineers or other agents can
execute. Never writes or modifies code.
## When to Use
@@ -19,7 +21,24 @@ Produce a plan that another engineer or agent can execute without guessing.
- A task feels too large or vague to start.
- Work needs to be parallelized across multiple agents or sessions.
Do not use for a small change with obvious scope or an existing executable plan.
Do **not** use this skill to actually implement anything — it is read-only.
**When NOT to use:** Single-file changes with obvious scope, or when the spec
already contains well-defined tasks.
## Role
You help users understand the Penpot codebase, design solutions, and produce
implementation plans that other agents or developers can execute. The plan
tells them what to build and how to verify it, task by task.
The implementer reads the project's agent docs (`AGENTS.md`, project memories
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
before working. Reference those memories instead of re-explaining tooling,
conventions, or test design — explain in the plan only what they do not cover.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the implementer's responsibility.
## CRITICAL: Required Reading Before Planning
@@ -36,36 +55,67 @@ Before drafting any plan, work through the project's own guidance:
Skipping this step is the #1 cause of incorrect or incomplete plans.
## Constraints
---
- You are **analysis-only** — never create, edit, or delete source code. The
only file you may write is the plan itself, and only when the command or
user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## The Planning Process
## Planning Process
### Phase 1: Architecture Analysis
1. Define the problem, desired outcome, constraints, and exclusions.
2. Trace the current behavior through the affected modules.
3. Map dependencies and choose an implementation order that builds foundations
before their consumers.
4. Identify open product or architecture decisions. Resolve implementation
details from existing conventions when they do not affect public behavior.
5. Identify edge cases, security and data risks, performance bounds, breaking
changes, and external dependencies.
6. Split the work into small, ordered tasks. Prefer complete testable slices
over unrelated layer-wide batches. Apply DRY and KISS to the proposed
implementation.
7. Define exact acceptance criteria and verification for every task.
8. Add a checkpoint after every two or three tasks in a longer plan.
9. State which tasks can run in parallel and which must remain sequential.
1. Read the spec, requirements, or feature request.
2. Analyze the codebase architecture and identify affected modules.
3. Read project conventions (starting with `critical-info` and module core
memories) before drafting.
4. Map dependencies between components (see the dependency graph in
`critical-info`).
5. Identify risks, edge cases, performance implications, and breaking changes.
## Task Format
### Phase 2: Task Breakdown
#### Identify the Dependency Graph
Map what depends on what, following the monorepo's module dependency graph:
```
common (shared types, schemas — no deps)
├── backend (depends common)
│ ├── RPC handlers
│ └── persistence / migrations
├── frontend (depends common, render-wasm)
│ ├── UI components
│ └── state / API integration
├── exporter (depends common)
└── render-wasm (consumed by frontend)
```
Implementation order follows the dependency graph bottom-up: build shared
foundations first, then layer consumers on top.
#### Slice Vertically
Instead of building all of common, then all of backend, then all of frontend —
build one complete feature path at a time:
**Bad (horizontal slicing):**
```
Task 1: Build all common types
Task 2: Build all backend handlers
Task 3: Build all frontend components
```
**Good (vertical slicing):**
```
Task 1: common data types + schema ← foundation
Task 2: backend RPC handler + persistence
Task 3: frontend UI component + API integration
```
Each vertical slice delivers working, testable functionality.
#### Write Tasks
Each task follows this structure:
@@ -102,16 +152,17 @@ implementation. Omit when the task is mechanical.
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
```
Use commands from `mem:testing` and affected module memories. Never substitute
generic text such as "run the tests" when the project documents an exact
command.
Replace "module-specific test command" with the actual commands for the module
(e.g. `clojure -M:dev:test` for backend/common,
`npx shadow-cljs compile test && npx karma start` for frontend, or the
commands noted in the module's core memory).
When possible, design each task with TDD in mind: acceptance criteria double as a test
list, and the natural first step of the task is writing those tests before the
implementation. Some tasks resist this (config, migrations, pure wiring) — for those, keep
the usual verification steps.
When possible, design each task with TDD in mind: acceptance criteria double
as a test list, and the natural first step of the task is writing those tests
before the implementation. Some tasks resist this (config, migrations, pure
wiring) — for those, keep the usual verification steps.
## Task Sizing
#### Estimate Scope
| Size | Files | Scope | Example |
|------|-------|-------|---------|
@@ -121,11 +172,16 @@ the usual verification steps.
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
| **XL** | 8+ | **Too large — break it down further** | — |
Split a task when it contains independent outcomes, spans unrelated systems, or cannot be
completed and verified in one focused session (if a task is XL, it should be broken into
smaller tasks; agents perform best on S and M tasks).
If a task is XL, it should be broken into smaller tasks. Agents perform best
on S and M tasks.
## Task order and checkpoints
**When to break a task down further:**
- It would take more than one focused session
- You cannot describe the acceptance criteria in 3 or fewer bullet points
- It touches two or more independent subsystems
- You find yourself writing "and" in the task title (a sign it is two tasks)
#### Order and Checkpoints
Arrange tasks so that:
@@ -141,42 +197,152 @@ Add explicit checkpoints with the relevant module commands:
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
```
## Requirements
- Analyze the codebase architecture and identify affected modules.
- Read project conventions before drafting (start with `critical-info` and
affected module core memories).
- Break down complex features or bugs into atomic, actionable steps.
- Propose solutions with clear rationale, trade-offs, and sequencing.
- Identify risks, edge cases, performance implications, and breaking changes.
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
- Every task must have acceptance criteria and verification steps.
- Checkpoints must exist after every 2-3 tasks.
## Constraints
- You are **analysis-only** — never create, edit, or delete source code. The
only file you may write is the plan itself, and only when the command or
user explicitly instructs you to save it.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
## Output Format
The plan is always delivered in the response so the user sees it regardless
of which agent is running the skill. File writes follow `Constraints`
by default announce the path instead of writing.
of which agent is running the skill. By default you never write the plan file;
announce the path instead. Write the file only when the command or user
explicitly instructs you to save it — and then only that file.
Announce the save path `.agents/plans/YYYY-MM-DD-<slug>.md` (today's date,
lowercase hyphen-separated slug, e.g. `2026-09-10-add-batch-get-profiles`;
an explicit user path wins).
Announce the suggested save path:
```
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
```
Use today's date in the user's local timezone. The `<plan-one-line-title>`
slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
provides a target file path, announce that path instead of the default.
End the response by suggesting the next steps: `/review-plan` to get a second
opinion on the plan and `/implement-plan` to execute it.
### Plan Structure
Use this document shape:
### Plan Document Template
```markdown
# Plan: Title
# Plan: [Feature/Project Name]
## Context
## Affected Modules
## Architecture Decisions
## Risks and Considerations
## Approach
## Task List
## Verification and Testing
## Parallelization
## Open Questions
```
[One paragraph: what is the problem or feature request? Why is it needed?]
Omit empty sections only when they do not apply. Every implementation task
still requires acceptance criteria, verification, dependencies, likely files,
and scope.
## Affected Modules
[Which modules of the monorepo are involved? Reference module paths and any
`mem:` memories that were consulted.]
## Architecture Decisions
- [Key decision 1 and rationale]
- [Key decision 2 and rationale]
## Risks & Considerations
[Edge cases, performance implications, breaking changes, migration concerns,
security implications.]
## Approach
[A short strategy summary: 3-5 sentences describing the overall approach and
the shape of the dependency graph (what depends on what, what gets built
first). High-level only — the task-by-task detail lives in the Task List.]
## Task List
Each task uses the full task structure defined in
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
verification, dependencies, files, estimated scope, and optional code sketch.
Never reduce a task to a one-line checkbox; the plan must be self-contained
and executable without other context.
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
into phases, milestones, or sprints; ordering and dependencies are already
captured per task. Insert a checkpoint after every 2-3 tasks.
## Task 1: [Short descriptive title]
**Description:** [What this task accomplishes.]
**Rationale:** [Why this approach over the alternatives.]
**Acceptance criteria:**
- [ ] [Specific, testable condition]
**Verification:**
- [ ] Relevant tests pass (module-specific command).
**Dependencies:** None
**Files likely touched:**
- `path/to/file`
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
**Code sketch (optional):** [Short contract-level example, only if the shape
is non-obvious.]
## Task 2: [Short descriptive title]
[Same structure as Task 1.]
## Task 3: [Short descriptive title]
[Same structure as Task 1.]
### Checkpoint: After Tasks 1-3
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
## Task 4: [Short descriptive title]
[Same structure as Task 1.]
## Task 5: [Short descriptive title]
[Same structure as Task 1.]
## Verification & Testing
[How to verify each task and the whole plan: the project's real test, lint,
build, and run commands (extracted during Required Reading), coverage
expectations, and manual checks. Consult each module's core memory for the
exact commands.]
## Parallelization Opportunities
- **Safe to parallelize:** Independent feature slices across separate
modules, tests for already-implemented features, documentation
- **Must be sequential:** Shared common schema changes, database migrations
- **Needs coordination:** Features that share a contract (define the contract
first, then parallelize)
## Open Questions
- [Question needing human input]
```
When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** and **Task List** sections and
@@ -191,6 +357,15 @@ lead with **Findings** instead, keeping the rest of the structure.
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
## Red Flags
- Delivering prose without a task breakdown
- Tasks that say "implement the feature" without acceptance criteria
- No verification steps in the plan
- All tasks are XL-sized
- No checkpoints between tasks
- Dependency order isn't considered
## Verification Checklist
Before delivering the plan, confirm:
+11 -125
View File
@@ -357,39 +357,6 @@ Insert the new version section right after the `# CHANGELOG` header (before
the previous version entry). Use the `edit` tool with enough context to make
a unique match.
### 8b. Propose and populate the `:rocket: Epics and highlights` subsection
After inserting the version section, proactively create or populate the
`### :rocket: Epics and highlights` subsection. This section surfaces the
most impactful changes for self-hosted users checking for updates.
**When to create:** If the version section does not already have a
`### :rocket: Epics and highlights` subsection, create one. Place it before
`### :sparkles:` (matching existing order in CHANGES.md).
**How to identify highlights:** Review the `:sparkles:` entries for the
version and select 25 of the most impactful/user-visible ones. Criteria:
- New user-visible features (not internal refactors)
- Significant capability additions
- Items that create "FOMO" for self-hosted users on older versions
**Use release notes as hints:** Check
`frontend/src/app/main/ui/releases/v2_<MINOR>.cljs` for the corresponding
version. The slide titles and feature descriptions there are curated
marketing content indicating what the team considers highlight-worthy. Match
those themes to changelog entries. Treat these files as optional hints — they
may not exist for every version.
**Format requirement:** Every `:rocket:` entry MUST follow the standard
changelog format with issue/PR references:
```
- <description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
An entry without issue AND PR references is a highlight gap (warning, not an anomaly).
**Preserve existing entries:** If the `:rocket:` section already exists from
a prior run, preserve its entries. Do not remove or rewrite them.
### 9. Verify
Read the top of `CHANGES.md` and confirm:
@@ -501,8 +468,9 @@ Markdown viewer.
## What is an anomaly
**An anomaly is a milestone-mismatch between an issue and its referenced
PR.** There are two anomaly types, plus two highlight gaps (warnings that
do not count toward the anomaly total):
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
all in milestone M" is inconsistent with the actual milestone assignments.
There are exactly two types:
1. **Issue is in the milestone, but its referenced PR is in a different
milestone (or has no milestone).** The changelog claims a fix in this
@@ -518,13 +486,6 @@ do not count toward the anomaly total):
PR that closes an issue with no milestone references an issue from
another (probably private) project; that is expected and the issue is
not part of this changelog. Do not report it.
3. **missing-highlights (gap):** A released X.Y.0 version section has no
`### :rocket: Epics and highlights` subsection. Patches (X.Y.Z) never
carry highlights, so only minors/majors are checked.
4. **missing-highlight-reference (gap):** A `:rocket:` entry lacks the
required issue AND PR references. Every highlight entry must follow the
standard changelog format with `[#ISSUE]` and `(PR: [#PR])` links
(multi-PR `(PR: [#A](...), [#B](...))` accepted).
**Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR
@@ -692,40 +653,6 @@ for pr_num in sorted(changelog_prs):
'issue_milestone': issue_ms, # may be None
})
# --- Type C: released X.Y.0 version sections without :rocket: subsection ---
# Patches (X.Y.Z with Z != 0) never carry :rocket: by design — only minors/majors (X.Y.0).
anomalies_c = [] # list of version strings
rocket_heading_re = re.compile(r'^### :rocket:', re.MULTILINE)
version_sections = re.split(r'(?=^## \d+\.\d+\.\d+)', content, flags=re.MULTILINE)
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver, suffix = m.group(1), m.group(2)
if 'unreleased' in suffix.lower(): continue
if ver.split('.')[2] != '0': continue
if not rocket_heading_re.search(vs):
anomalies_c.append(ver)
# --- Type D: :rocket: entries without issue AND PR references ---
# Both are required: `[#ISSUE](.../issues/N)` and `(PR: [#PR](.../pull/M))`.
# Multi-PR entries `(PR: [#A](...), [#B](...))` are accepted.
anomalies_d = [] # list of dicts: {version, line}
issue_ref_re = re.compile(r'\[#\d+\]\(https://github\.com/penpot/penpot/issues/\d+\)')
pr_ref_re = re.compile(r'\(PR:\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\)(\s*,\s*\[#\d+\]\(https://github\.com/penpot/penpot/pull/\d+\))*\)')
for vs in version_sections:
m = re.match(r'^## (\d+\.\d+\.\d+)(.*)', vs)
if not m: continue
ver = m.group(1)
rocket_match = rocket_heading_re.search(vs)
if not rocket_match: continue
# Extract the :rocket: subsection body (up to next ### or ##)
rocket_body = vs[rocket_match.end():]
rocket_body = re.split(r'(?m)^#{2,3}\s', rocket_body)[0]
for line in rocket_body.splitlines():
line = line.strip()
if line.startswith('- ') and not (issue_ref_re.search(line) and pr_ref_re.search(line)):
anomalies_d.append({'version': ver, 'line': line[:100]})
# --- Write report ---
def fmt_ms(ms):
return ms if ms else "_none_"
@@ -737,17 +664,13 @@ with open(OUTPUT, 'w') as f:
n_a = len(anomalies_a)
n_b = len(anomalies_b)
n_c = len(anomalies_c)
n_d = len(anomalies_d)
f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n')
f.write(f'- **Released X.Y.0 version missing :rocket: section (gap):** {n_c}\n')
f.write(f'- **:rocket: entry without issue AND PR references (gap):** {n_d}\n\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
# --- Anomalies section (milestone mismatches only) ---
# --- Anomalies section ---
if n_a or n_b:
f.write('## Anomalies\n\n')
f.write('These are milestone mismatches between an issue in the changelog '
@@ -786,37 +709,9 @@ with open(OUTPUT, 'w') as f:
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
f.write('\n')
else:
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
# --- Highlight gaps (warnings, not anomalies) ---
if n_c or n_d:
f.write('## Highlight gaps\n\n')
f.write('These are warnings, not anomalies: they do not affect the '
'milestone-mismatch total above. They track `:rocket:` coverage '
'across all released X.Y.0 versions. Historical entries (e.g. '
'Taiga links) predate the current reference convention and are '
'expected to appear here.\n\n')
if n_c:
f.write(f'### Released X.Y.0 version missing :rocket: section\n\n')
f.write('These released minors/majors have no `### :rocket: Epics and highlights` subsection. '
'Add highlights to help self-hosted users understand what they are missing.\n\n')
for ver in anomalies_c:
f.write(f'- Version **{ver}**\n')
f.write('\n')
if n_d:
f.write(f'### :rocket: entry without issue AND PR references\n\n')
f.write('These highlight entries lack the required issue AND PR references. '
'Add `[#ISSUE](...)` and `(PR: [#PR](...))` links.\n\n')
for d in anomalies_d:
f.write(f'- **{d["version"]}**: `{d["line"]}`\n')
f.write('\n')
elif not (n_a or n_b):
f.write('✅ No highlight gaps found. All released X.Y.0 versions have properly referenced :rocket: entries.\n\n')
# --- Context ---
f.write('---\n\n')
f.write('## Context\n\n')
@@ -831,7 +726,8 @@ print(f"Anomaly report written to {OUTPUT}")
PYEOF
```
This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
This generates `CHANGES-ISSUES.md` containing **only the anomalies**
milestone mismatches between issues and their referenced PRs:
1. **Issue in milestone, referenced PR in different milestone or no milestone**
the changelog claims a fix here, but the PR is released elsewhere.
@@ -840,13 +736,6 @@ This generates `CHANGES-ISSUES.md` containing anomalies and highlight gaps:
(An issue with *no* milestone belongs to another, probably private,
project — milestones are only required on the "Main" project — so it is
neither an anomaly nor a changelog candidate.)
3. **missing-highlights (gap, warning)** — a released X.Y.0 version section
has no `### :rocket: Epics and highlights` subsection. Patches (X.Y.Z)
never carry highlights.
4. **missing-highlight-reference (gap, warning)** — a `:rocket:` entry lacks
the required issue AND PR references.
Gaps do not count toward the anomaly total.
**Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
@@ -920,13 +809,10 @@ self-contained and clickable in any Markdown viewer.
issue from a different project or context. If the PR title and issue title
are clearly unrelated, or the PR predates the issue by years, treat it as a
data glitch and skip it.
- **Anomaly = milestone mismatch only; gaps are warnings.** The report's
anomaly total counts only milestone mismatches: (1) the issue is in this
milestone but the referenced PR is in a different milestone (or unassigned),
and (2) the PR is in this milestone but the issue it closes is in a
different milestone. `:rocket:` highlight gaps (missing section on a
released X.Y.0, entry without issue AND PR references) are reported in a
separate `Highlight gaps` section and never count toward the anomaly total. An
- **Anomaly = milestone mismatch only.** The report contains only milestone
mismatches: (1) the issue is in this milestone but the referenced PR is
in a different milestone (or unassigned), and (2) the PR is in this
milestone but the issue it closes is in a different milestone. An
*unassigned* (milestone-less) issue closed by a milestone PR is **not**
an anomaly: milestones are required only for the "Main" project, so such
issues come from another (probably private) project and are not changelog
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
build-and-push:
name: Build and push DevEnv Docker image
runs-on: penpot-standar-runner
runs-on: penpot-extended-runner
steps:
- name: Set common environment variables
+4 -4
View File
@@ -46,7 +46,7 @@ jobs:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
name: Prepare
runs-on: penpot-standar-runner
runs-on: penpot-extended-runner
timeout-minutes: 15
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
@@ -135,7 +135,7 @@ jobs:
# ── 2. One build per image, in parallel, only when needed ──────────────
build:
name: Build ${{ matrix.image }}
runs-on: penpot-standar-runner
runs-on: penpot-extended-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.exists == 'false'
@@ -248,7 +248,7 @@ jobs:
# the S3 marker guarantees the branch tags were already moved.
promote:
name: Promote image set
runs-on: penpot-standar-runner
runs-on: penpot-extended-runner
timeout-minutes: 10
needs: [prepare, build]
@@ -302,7 +302,7 @@ jobs:
# ── 4. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-standar-runner
runs-on: penpot-extended-runner
timeout-minutes: 5
needs: [prepare, build, promote]
if: failure()
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
notify:
name: Notifications
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs:
- build-docker
- build-docker-admin-console
+1 -1
View File
@@ -19,7 +19,7 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.vars.outputs.gh_ref }}
release_notes: ${{ steps.extract_release_notes.outputs.release_notes }}
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
test-exporter:
if: ${{ !github.event.pull_request.draft }}
name: "Exporter Tests"
runs-on: penpot-extended-runner
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
+1
View File
@@ -24,6 +24,7 @@ opencode.json
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
!HIGHLIGHTS.md
/*.png
/*.svg
/*.sql
+2 -3
View File
@@ -86,8 +86,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `
| `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 and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | No reference scan. A touched object is deleted after the delay; `gc-deleted` removes mappings before rows. |
| `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. |
@@ -96,7 +95,7 @@ Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `
- `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`, `upload-session`, and `organization`.
- 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
+1 -1
View File
@@ -11,7 +11,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
- Before `git commit``mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer)
- Before `git commit``mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit``mem:workflow/creating-prs` (title format, body structure, "Note:" line)
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
+2 -22
View File
@@ -14,32 +14,12 @@ automatically pull the identity from the local git config `user.name` and `user.
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why.
Wrap lines at 76 characters — git log adds a
four-space indent, so 76 + 4 fits an 80-column
terminal. Keep each line concise.
Wrap lines at 72 characters — git log and tooling
render long lines poorly. Keep each line concise.
AI-assisted-by: model-name
```
## HARD RULES (inexcusable)
These rules are not advisory. Do not commit until every one holds. A commit
that breaks them is wrong, even if the code is right.
- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do
not eyeball it. This is the rule most often skipped. Rationale: `git log`
indents the body four spaces, so 76 + 4 fits an 80-column terminal.
- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period.
- **MUST be a blank line** between subject and body.
- **MUST run `scripts/check-commit` and get exit code 0 before finishing.**
It mechanically validates the rules above; a failing run is a blocker.
- It checks `HEAD` by default: `./scripts/check-commit`
- For another commit: `./scripts/check-commit -c <ref>`
- **NEVER** hand-wave the body as "one long line". If a line exceeds 76,
break it at a space.
- Exceptions inside the body (do not wrap these): `Signed-off-by:`,
`Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL.
**AI-assisted-by trailer rules:**
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
- Do NOT add prefixes like `opencode-go/` — use the bare model name
+4 -6
View File
@@ -52,12 +52,10 @@ file (never pipe tool output through filters).
then re-run `corepack use pnpm@<tag>` in that directory.
- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
a placeholder scaffold into its `pnpm-workspace.yaml`:
`allowBuilds: esbuild: set this to true or false`. Current pnpm writes
only the `allowBuilds` placeholder; any legacy key still present
(`ignoredBuiltDependencies`, `onlyBuiltDependencies`,
`neverBuiltDependencies`) is ignored since pnpm 11. Repo convention is
`allowBuilds: esbuild: true`. Replace the placeholder and drop the
legacy entry, then re-run.
`allowBuilds: esbuild: set this to true or false` plus
`ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
then re-run.
- `plugins/apps/composable-test-suite` once had its own
`pnpm-workspace.yaml` and acted as a nested workspace root. That state is
gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
-6
View File
@@ -14,12 +14,6 @@
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
This prevents hiding test failures. See `mem:testing` for details.
- **`.claude/skills` is a symlink to `.agents/skills`.**
Edit skills only in their canonical location (`.agents/skills`); never edit
through `.claude/skills`.
- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and
the commit MUST pass `./scripts/check-commit` with exit code 0 before you
consider it done. This is mechanically checked — do not eyeball it.
- **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)
-23
View File
@@ -156,21 +156,6 @@
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
- Fix missing text in legacy SVG board thumbnails [#10182](https://github.com/penpot/penpot/issues/10182) (PR: [#11552](https://github.com/penpot/penpot/pull/11552))
- Fix workspace crash when applying transform modifiers in the WASM renderer [#10894](https://github.com/penpot/penpot/issues/10894) (PR: [#10896](https://github.com/penpot/penpot/pull/10896))
- Limit ZIP entry count and object size on V3 binfile import [#11021](https://github.com/penpot/penpot/issues/11021) (PR: [#11022](https://github.com/penpot/penpot/pull/11022))
- Block plugin UI iframe URLs targeting the Penpot domain [#11271](https://github.com/penpot/penpot/issues/11271) (PR: [#11273](https://github.com/penpot/penpot/pull/11273))
- Restrict the MCP REPL code execution endpoint to development environments [#11283](https://github.com/penpot/penpot/issues/11283) (PR: [#11282](https://github.com/penpot/penpot/pull/11282))
- Filter share-link tokens from the get-view-only-bundle response [#11285](https://github.com/penpot/penpot/issues/11285) (PR: [#11286](https://github.com/penpot/penpot/pull/11286))
- Disable MCP developer tools in multi-user mode [#11291](https://github.com/penpot/penpot/issues/11291) (PR: [#11310](https://github.com/penpot/penpot/pull/11310))
- Fix Hide comments setting being ignored after opening the Comments section [#11308](https://github.com/penpot/penpot/issues/11308) (PR: [#11492](https://github.com/penpot/penpot/pull/11492))
- Block NAT64/6to4/Teredo IPv6 transition addresses in the SSRF guard [#11319](https://github.com/penpot/penpot/issues/11319) (PR: [#11320](https://github.com/penpot/penpot/pull/11320))
- Prevent team admins from removing the team owner [#11367](https://github.com/penpot/penpot/issues/11367) (PR: [#11368](https://github.com/penpot/penpot/pull/11368))
- Enforce share-link comment permissions and page scope [#11370](https://github.com/penpot/penpot/issues/11370) (PR: [#11371](https://github.com/penpot/penpot/pull/11371))
- Clean up orphaned teams, projects and files on profile deletion [#11394](https://github.com/penpot/penpot/issues/11394) (PR: [#11395](https://github.com/penpot/penpot/pull/11395))
- Fix crash when pressing Ctrl+D with no shape selected [#11448](https://github.com/penpot/penpot/issues/11448) (PR: [#11491](https://github.com/penpot/penpot/pull/11491))
- Fix text layout not updating when auto-width is set by double-clicking the bounding box [#11480](https://github.com/penpot/penpot/issues/11480) (PR: [#11541](https://github.com/penpot/penpot/pull/11541))
- Fix boolean shapes rendering deformed in the WASM renderer and exports [#11482](https://github.com/penpot/penpot/issues/11482) (PR: [#11551](https://github.com/penpot/penpot/pull/11551))
### :sparkles: New features & Enhancements
@@ -233,10 +218,6 @@
### :rocket: Epics and highlights
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
- Add layer blur effect for visual depth and styling [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
- Render guides in WebGL for consistent viewer performance [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add concurrency limiter and status indicators for MCP server communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
- Add typography token row to multiselected texts for better token visibility [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
### :sparkles: New features & Enhancements
@@ -591,10 +572,6 @@
## 2.15.0
### :rocket: Epics and highlights
- Add MCP server integration for AI-assisted design workflows [#9174](https://github.com/penpot/penpot/issues/9174) (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321))
### :sparkles: New features & Enhancements
- Add MCP server integration [GH #9174](https://github.com/penpot/penpot/issues/9174)
-3
View File
@@ -188,11 +188,8 @@ Commit messages must follow this format:
- Add clear and concise description on the body
- Do not end the subject with a period
- Keep the subject to **70 characters** or fewer
- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted)
- Separate the subject from the body with a **blank line**
You can check a commit against these rules with `./scripts/check-commit`.
### Examples
```
+26
View File
@@ -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
-7
View File
@@ -56,7 +56,6 @@ If your organization is scaling and needs extra support, were here to help. [
- [Why Penpot](#why-penpot)
- [Getting Started](#getting-started)
- [Penpot Enterprise](#penpot-enterprise)
- [Community](#community)
- [Contributing](#contributing)
- [Resources](#resources)
@@ -94,12 +93,6 @@ Penpot is the only design & prototype platform that is deployment agnostic. You
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
<img width="100%" height="1010" alt="2" src="https://github.com/user-attachments/assets/243e796e-a140-481a-b68f-b24be6a70e37" />
## Penpot Enterprise ##
Penpot Enterprise is our paid plan for organizations that need to scale their design work across multiple teams with advanced governance, security, and administration. Manage teams and access from a centralized **Admin Console**, configure advanced permissions, and connect your **identity provider through SSO**. Available for cloud and self-hosted environments, it combines enterprise controls with Penpots open-source foundation and open standards.
## Community ##
We love the Open Source software community. Contributing is our passion and if its yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
+1 -1
View File
@@ -31,7 +31,7 @@ export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
enable-login-with-password \
enable-login-with-ldap \
disable-login-with-ldap \
disable-login-with-oidc \
disable-login-with-google \
disable-login-with-github \
+6 -24
View File
@@ -10,7 +10,7 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[clj-ldap.client :as ldap]
[cuerdas.core :as str]
[clojure.string]
[integrant.core :as ig]))
(defn- prepare-params
@@ -36,22 +36,11 @@
:cause cause))))
(defn- replace-several [s & {:as replacements}]
(reduce-kv str/replace s replacements))
(defn- escape-ldap-filter-value
"Escapes special characters in a string for use in LDAP filter values,
per RFC 4515 section 3."
[s]
(-> s
(str/replace "\\" "\\5c")
(str/replace "*" "\\2a")
(str/replace "(" "\\28")
(str/replace ")" "\\29")
(str/replace "\u0000" "\\00")))
(reduce-kv clojure.string/replace s replacements))
(defn- search-user
[{:keys [::conn base-dn] :as cfg} email]
(let [query (replace-several (:query cfg) ":username" (escape-ldap-filter-value email))
(let [query (replace-several (:query cfg) ":username" email)
attrs [(:attrs-username cfg)
(:attrs-email cfg)
(:attrs-fullname cfg)]
@@ -60,19 +49,12 @@
:attributes attrs}]
(first (ldap/search conn base-dn params))))
(defn- get-attr
"Retrieves an attribute from an LDAP entry. Handles multi-valued
attributes by returning the first value."
[entry attr-key]
(let [v (get entry attr-key)]
(if (coll? v) (first v) v)))
(defn- retrieve-user
[{:keys [::conn] :as cfg} {:keys [email password]}]
(when-let [{:keys [dn] :as user} (search-user cfg email)]
(when (ldap/bind? conn dn password)
{:fullname (get-attr user (-> cfg :attrs-fullname keyword))
:email (get-attr user (-> cfg :attrs-email keyword))
{:fullname (get user (-> cfg :attrs-fullname keyword))
:email email
:backend "ldap"})))
(def ^:private schema:info-data
@@ -97,7 +79,7 @@
(l/warn :hint "invalid response from ldap, looks like ldap is not configured correctly" :data user)
(ex/raise :type :restriction
:code :wrong-ldap-response
::sm/explain explain)))
:explain explain)))
user)))
(defn- try-connectivity
-2
View File
@@ -92,7 +92,6 @@
:quotes-upload-sessions-per-profile 5
:quotes-upload-chunks-per-session 20
:upload-max-chunk-size (* 1024 1024 30) ; 30MiB
;; SSRF protection
:ssrf-allowed-hosts #{}
@@ -204,7 +203,6 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:upload-max-chunk-size {:optional true} ::sm/int]
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string]
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.arrow
"Bulk Ladybug ingest through in-memory Arrow.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.debug
"In-memory Ladybug sessions for the debug graph console."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.ingest
"Penpot file -> Ladybug graph projection."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.ladybug
"Ladybug access layer for graph-backed Penpot.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.meta
"`GraphMeta`: the graph's own account of who built it and from what.
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.projection.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.projection.transforms
"Derived graph links: edges a reader could compute from the projected
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.report
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.contract
"Deliberate choices in Penpot's graph schema, recorded as data.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.nodes
"Single source of truth for graph node tables.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.projection
"Derive Ladybug node column schemas from Penpot Malli sources.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.types
"Map Malli schemas to Ladybug column types.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.schema.values
"Shape a Penpot value into the plain data its Ladybug column type wants.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.stats
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.graph.sync
"Incremental Ladybug graph updates from Penpot file-change events."
+3 -8
View File
@@ -15,7 +15,6 @@
(:require
[app.common.schema :as sm]
[app.util.ssrf :as ssrf]
[app.worker :as-alias wrk]
[cuerdas.core :as str]
[integrant.core :as ig]
[java-http-clj.core :as http])
@@ -24,8 +23,6 @@
java.net.URI))
(def default-max-redirects 5)
(def default-connect-timeout 30000)
(def default-request-timeout 30000)
(defn client?
[o]
@@ -36,17 +33,15 @@
:pred client?})
(defmethod ig/init-key ::client
[_ {:keys [::wrk/executor]}]
(http/build-client {:connect-timeout default-connect-timeout
:executor executor
[_ _]
(http/build-client {:connect-timeout 30000
:follow-redirects :never}))
(defn send!
([client req] (send! client req {}))
([client req {:keys [response-type] :or {response-type :string}}]
(assert (client? client) "expected valid http client")
(http/send (merge {:timeout default-request-timeout} req)
{:client client :as response-type})))
(http/send req {:client client :as response-type})))
(defn- resolve-client
[params]
+1 -7
View File
@@ -60,13 +60,7 @@
(defmethod handle-error :restriction
[err request _]
(let [data (ex-data err)
code (get data :code)
explain (ex/explain data)
data (-> data
(dissoc ::sm/explain)
(cond-> explain (assoc :explain explain)))]
(let [{:keys [code] :as data} (ex-data err)]
(if (= code :method-not-allowed)
{::yres/status 405
::yres/body data}
+8 -1
View File
@@ -205,7 +205,7 @@
::sto/storage (ig/ref ::sto/storage)}
::http.client/client
{::wrk/executor (ig/ref ::wrk/executor)}
{}
::session/manager
{::db/pool (ig/ref ::db/pool)}
@@ -390,6 +390,7 @@
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
:telemetry (ig/ref :app.tasks.telemetry/handler)
:upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
:storage-gc-touched (ig/ref ::sto.gc-touched/handler)
:storage-pending-gc (ig/ref ::sto.pending-gc/handler)
@@ -428,6 +429,9 @@
:app.tasks.tasks-gc/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.upload-session-gc/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.objects-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
@@ -560,6 +564,9 @@
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
:task :tasks-gc}
{:cron #penpot/cron "0 0 0 * * ?" ;; daily
:task :upload-session-gc}
{:cron #penpot/cron "0 0 2 * * ?" ;; daily
:task :file-gc-scheduler}
+3 -3
View File
@@ -75,10 +75,10 @@
{:method method
:uri uri
:body body
:headers headers
:timeout timeout}
:headers headers}
{:response-type :input-stream
:skip-ssrf-check? true})
:skip-ssrf-check? true
:timeout timeout})
status (:status resp)]
(when (not (<= 200 status 299))
(let [body (:body resp)]
+1 -4
View File
@@ -502,10 +502,7 @@
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}
{:name "0153-add-storage-object-status-and-deletion-attempts"
:fn (mg/resource "app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql")}
{:name "0154-add-upload-session-chunk-table"
:fn (mg/resource "app/migrations/sql/0154-add-upload-session-chunk-table.sql")}])
:fn (mg/resource "app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql")}])
(defn apply-migrations!
[pool name migrations]
@@ -1,61 +0,0 @@
--- Add the upload_session_chunk table, a deleted_at marker to upload_session,
--- and make the upload_session.profile_id foreign key non-deleting.
--- Each row maps one chunk of a chunked-upload session to the storage_object
--- row that holds its bytes. Both foreign keys are ON DELETE NO ACTION
--- DEFERRABLE on purpose: neither the session nor the storage object can be
--- removed while a mapping row exists. Only objects-gc removes mappings
--- (for consumed, stalled and profile-purge sessions), always before the
--- session row, touching the chunk objects so storage GC reclaims them.
--- NO ACTION is identical to RESTRICT in normal
--- (immediate) operation; only the deferrability differs, which tooling
--- such as the backend test fixture relies on
--- (SET CONSTRAINTS ALL DEFERRED).
---
--- object_id is nullable: the mapping row is inserted first (reserving the
--- slot under the UNIQUE(session_id, chunk_index) constraint inside a
--- transaction that locks the session), and object_id is set once the blob
--- has been written outside the transaction. A mapping with NULL object_id
--- and no in-flight upload behind it means that upload died mid-flight; the
--- client then starts a new session (sessions are ephemeral).
CREATE TABLE upload_session_chunk (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
session_id uuid NOT NULL REFERENCES upload_session(id) ON DELETE NO ACTION DEFERRABLE,
object_id uuid NULL REFERENCES storage_object(id) ON DELETE NO ACTION DEFERRABLE,
chunk_index integer NOT NULL,
UNIQUE (session_id, chunk_index)
);
--- No standalone index on session_id: the UNIQUE(session_id, chunk_index)
--- btree already serves session_id-only lookups and the session FK check
--- via its leftmost column.
CREATE INDEX upload_session_chunk__object_id__idx
ON upload_session_chunk(object_id);
--- The deleted_at column marks a session as consumed: assemble-chunks sets it
--- instead of deleting the row, and objects-gc physically purges consumed
--- sessions (as well as stalled ones that were never assembled).
ALTER TABLE upload_session
ADD COLUMN deleted_at timestamptz NULL DEFAULT NULL;
CREATE INDEX upload_session__deleted_at_created_at__idx
ON upload_session(deleted_at, created_at);
--- The profile foreign key moves from CASCADE to NO ACTION DEFERRABLE:
--- sessions must be purged procedurally (objects-gc drains the sessions of
--- profiles pending purge before the profile row is deleted), so a profile
--- can no longer disappear with live chunk mappings behind it.
ALTER TABLE upload_session
DROP CONSTRAINT upload_session_profile_id_fkey;
ALTER TABLE upload_session
ADD CONSTRAINT upload_session_profile_id_fkey
FOREIGN KEY (profile_id) REFERENCES profile(id) ON DELETE NO ACTION DEFERRABLE;
+6 -14
View File
@@ -390,26 +390,18 @@
(def ^:private sql:file-comment-users
"WITH available_profiles AS (
SELECT DISTINCT c.owner_id AS id
FROM comment c
JOIN comment_thread ct
ON ct.id = c.thread_id
WHERE ct.file_id = ?::uuid
),
profile_ids AS (
SELECT id FROM available_profiles
UNION
SELECT ?::uuid
SELECT DISTINCT owner_id AS id
FROM comment
WHERE thread_id IN (SELECT id FROM comment_thread WHERE file_id=?)
)
SELECT p.id,
p.email,
p.fullname AS name,
p.fullname,
p.fullname AS fullname,
p.photo_id,
p.is_active
FROM profile p
JOIN profile_ids AS x
ON x.id = p.id;")
FROM profile AS p
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
(defn get-file-comments-users
[conn file-id profile-id]
+44 -130
View File
@@ -339,8 +339,6 @@
;; --- Chunked Upload: Upload a single chunk
(declare ^:private check-upload-chunk-slot)
(def ^:private schema:upload-chunk
[:map {:title "upload-chunk"}
[:session-id ::sm/uuid]
@@ -352,75 +350,13 @@
[:session-id ::sm/uuid]
[:index ::sm/int]])
(def ^:private sql:link-upload-session-chunk
"UPDATE upload_session_chunk
SET object_id = ?
WHERE session_id = ?
AND chunk_index = ?
AND object_id IS NULL")
(sv/defmethod ::upload-chunk
{::doc/added "2.17"
::sm/params schema:upload-chunk
::sm/result schema:upload-chunk-result}
[{:keys [::db/pool] :as cfg}
{:keys [::rpc/profile-id session-id index content] :as _params}]
(let [session (db/tx-run! cfg check-upload-chunk-slot session-id profile-id index content)]
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content))
;; NOTE: the blob is written outside any transaction on purpose (see
;; mem:backend/storage): a failed write must never mingle with the
;; mapping transaction. If the write fails, the reserved mapping is
;; removed and the error propagates, so the client retries the index
;; in the same session. If the process dies between the reserve and
;; the link below, a NULL mapping is left behind and the client starts
;; a new session (sessions are ephemeral).
(let [storage (sto/resolve cfg)
data (sto/content (:path content))
object (try
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touched-at (ct/in-future {:hours 1})
:content-type (:mtype content)
:bucket sto/upload-session-bucket})
(catch Throwable cause
(db/delete! pool :upload-session-chunk
{:session-id session-id :chunk-index index})
(throw cause)))
linked (-> (db/exec-one! pool [sql:link-upload-session-chunk
(:id object) session-id index])
(db/get-update-count))]
(when (zero? linked)
;; The mapping vanished concurrently (session consumed or purged
;; after the reserve); the orphaned object stays touched so
;; touched-gc reclaims it.
(ex/raise :type :not-found
:code :object-not-found
:hint "upload session no longer available"
:session-id session-id))))
{:session-id session-id
:index index})
(defn- check-upload-chunk-slot
"Reserves the (session, index) slot: locks the session row, runs all
validations and inserts the mapping with a NULL object_id, all in one
transaction. Concurrent uploads of the same session serialize on the
session lock, so the UNIQUE(session_id, chunk_index) constraint can
never fire."
[{:keys [::db/conn]} session-id profile-id index content]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id} {::db/for-update true})]
(when (:deleted-at session)
(ex/raise :type :not-found
:code :object-not-found
:hint "upload session already consumed"
:session-id session-id))
(let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})]
(when (or (neg? index) (>= index (:total-chunks session)))
(ex/raise :type :validation
:code :invalid-chunk-index
@@ -429,46 +365,40 @@
:total-chunks (:total-chunks session)
:index index))
(when (> (:size content) (cf/get :upload-max-chunk-size))
(ex/raise :type :validation
:code :chunk-too-large
:hint "chunk size exceeds the maximum allowed"
:session-id session-id
:index index
:size (:size content)
:max-size (cf/get :upload-max-chunk-size)))
;; NOTE: a mapping with NULL object_id also counts as occupied: either
;; its upload is still in flight, or it died mid-flight and the client
;; must start a new session.
(when (db/get* conn :upload-session-chunk {:session-id session-id :chunk-index index})
(ex/raise :type :validation
:code :chunk-already-exists
:hint "chunk already uploaded for this session and index"
:session-id session-id
:index index))
(l/trc :hint "upload-chunk"
:session-id session-id
:chunk (str index "/" (:total-chunks session))
:size (:size content)
:path (:path content)))
(db/insert! conn :upload-session-chunk
{:session-id session-id
:object-id nil
:chunk-index index})
(let [storage (sto/resolve cfg)
data (sto/content (:path content))]
(sto/put-object! storage
{::sto/content data
::sto/deduplicate? false
::sto/touched-at (ct/in-future {:hours 1})
:content-type (:mtype content)
:bucket sto/tempfile-bucket
:upload-id (str session-id)
:chunk-index index}))
session))
{:session-id session-id
:index index})
;; --- Chunked Upload: shared helpers
(def ^:private sql:get-upload-session-chunks
"SELECT so.id, so.size
FROM upload_session_chunk AS usc
JOIN storage_object AS so ON (so.id = usc.object_id)
WHERE usc.session_id = ?
AND so.deleted_at IS NULL
AND so.status = 'valid'
ORDER BY usc.chunk_index ASC")
(def ^:private sql:get-upload-chunks
"SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index
FROM storage_object
WHERE (metadata->>'~:upload-id') = ?::text
AND deleted_at IS NULL
AND status = 'valid'
ORDER BY (metadata->>'~:chunk-index')::integer ASC")
(defn- get-upload-chunks
[conn session-id]
(db/exec! conn [sql:get-upload-session-chunks session-id]))
(db/exec! conn [sql:get-upload-chunks (str session-id)]))
(defn- concat-chunks
"Reads all chunk storage objects in order and writes them to a single
@@ -490,45 +420,29 @@
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` or
was already consumed. Marks the session row as consumed (`deleted_at`);
the chunk mappings stay until the objects-gc task purges them (touching
the chunk objects so storage GC reclaims them), and the session row is
purged afterwards."
Raises :not-found when the session does not belong to `profile-id`.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} profile-id session-id]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})]
(when (:deleted-at session)
(ex/raise :type :not-found
:code :object-not-found
:hint "upload session already consumed"
:session-id session-id))
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
chunks (get-upload-chunks conn session-id)]
(let [chunks (get-upload-chunks conn session-id)]
(when (not= (count chunks) (:total-chunks session))
(ex/raise :type :validation
:code :missing-chunks
:hint "number of stored chunks does not match expected total"
:session-id session-id
:expected (:total-chunks session)
:found (count chunks)))
(when (not= (count chunks) (:total-chunks session))
(ex/raise :type :validation
:code :missing-chunks
:hint "number of stored chunks does not match expected total"
:session-id session-id
:expected (:total-chunks session)
:found (count chunks)))
(let [storage (sto/resolve cfg ::db/reuse-conn true)
path (concat-chunks storage chunks)
size (reduce #(+ %1 (:size %2)) 0 chunks)]
(let [storage (sto/resolve cfg ::db/reuse-conn true)
path (concat-chunks storage chunks)
size (reduce #(+ %1 (:size %2)) 0 chunks)]
(db/delete! conn :upload-session {:id session-id})
;; NOTE: the session row is only marked (deleted_at) here; the
;; chunk mappings stay until the objects-gc task removes them
;; (before the session row, as the NO ACTION foreign keys
;; require) while touching the chunk objects.
(db/update! conn :upload-session
{:deleted-at (ct/now)}
{:id session-id}
{::db/return-keys false})
{:filename "upload"
:path path
:size size}))))
{:filename "upload"
:path path
:size size})))
;; --- Chunked Upload: Assemble all chunks into a final media object
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.rpc.commands.plugins
(:require
+2 -8
View File
@@ -41,7 +41,6 @@
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]
[app.util.ssrf :as ssrf]
[app.worker :as wrk]
[cuerdas.core :as str]))
@@ -961,18 +960,13 @@ RETURNING id, deleted_at;")
(sv/defmethod ::check-organization-sso
"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. The issuer URL is nitrate-supplied
(customer-configured), so it is checked against the SSRF blocklist before
any outbound request is attempted."
discovery before saving the settings."
{::doc/added "2.18"
::sm/params cto/schema:nitrate-sso
::sm/result schema:check-organization-sso-result
::rpc/auth false}
[cfg params]
(let [issuer (oidc/organization-sso-discovery-uri params)]
{:valid (boolean (and issuer
(ssrf/safe-url? issuer)
(oidc/is-organization-sso-config-valid? cfg params)))}))
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
+1 -2
View File
@@ -535,8 +535,7 @@
(def ^:private sql:get-upload-sessions-per-profile
"SELECT count(*) AS total
FROM upload_session
WHERE profile_id = ?
AND deleted_at IS NULL")
WHERE profile_id = ?")
(defmethod check-quote ::upload-sessions-per-profile
[{:keys [::profile-id ::target] :as quote}]
+1 -7
View File
@@ -42,10 +42,6 @@
"Bucket name for temporary file uploads (10-minute expiry)."
"tempfile")
(def upload-session-bucket
"Bucket name for chunked-upload chunks."
"upload-session")
(def valid-buckets
#{"file-media-object"
"team-font-variant"
@@ -54,7 +50,6 @@
"profile"
"organization"
tempfile-bucket
upload-session-bucket
"file-data"
"file-data-fragment"
"file-change"})
@@ -216,8 +211,7 @@
(if-some [hit (when (and (::deduplicate? params)
(:hash mdata)
(:bucket mdata)
(not= tempfile-bucket (:bucket mdata))
(not= upload-session-bucket (:bucket mdata)))
(not= tempfile-bucket (:bucket mdata)))
(get-database-object-by-hash pool backend
(:bucket mdata)
(:hash mdata)))]
-23
View File
@@ -57,18 +57,6 @@
(-> (db/exec-one! conn [sql:delete-sobjects ids])
(db/get-update-count))))
(def ^:private sql:delete-upload-session-chunks
"DELETE FROM upload_session_chunk
WHERE object_id = ANY(?::uuid[])")
(defn- delete-upload-session-chunks!
"Remove the chunk mappings for the given storage object ids. This must run
before the storage_object rows are deleted: the upload_session_chunk
foreign keys are ON DELETE NO ACTION."
[conn ids]
(let [ids (db/create-array conn "uuid" ids)]
(db/exec-one! conn [sql:delete-upload-session-chunks ids])))
(def ^:private sql:increment-attempts-and-defer
"UPDATE storage_object
SET deletion_attempts = deletion_attempts + 1,
@@ -117,21 +105,10 @@
:backend (name backend-id)))
(when (seq ok-ids)
;; NOTE: the chunk mappings must be removed before the
;; storage_object rows (NO ACTION foreign keys). It only affects
;; objects of the upload-session bucket; for any other bucket the
;; delete matches no rows.
(delete-upload-session-chunks! conn ok-ids)
(delete-sobjects! conn ok-ids))
(when (seq fail-ids)
(increment-attempts-and-defer! conn fail-ids)
;; NOTE: same NO ACTION ordering as above: the give-up DELETE below
;; removes storage_object rows, so chunk mappings must go first.
;; Deferred objects keep their rows; only the mapping of a
;; permanently given-up object disappears early, and that object is
;; already deleted-marked.
(delete-upload-session-chunks! conn fail-ids)
(let [given-up (delete-give-up! conn fail-ids)]
(when (pos? (db/get-update-count given-up))
(l/wrn :hint "giving up on orphan blob after max attempts"
-1
View File
@@ -162,7 +162,6 @@
(= 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 sto/upload-session-bucket) (process-objects! conn (constantly false) sto/upload-session-bucket objects)
(= bucket "organization") (process-objects! conn (constantly false) bucket objects)
:else
(ex/raise :type :internal
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.storage.pending-gc
"A maintenance task that reclaims storage objects created in 'pending'
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
+1 -50
View File
@@ -16,51 +16,6 @@
[app.tasks.delete-object :as dobj]
[integrant.core :as ig]))
(def ^:private sql:get-upload-sessions
"SELECT us.id
FROM upload_session AS us
WHERE (us.deleted_at IS NOT NULL
AND us.deleted_at <= ?)
OR (us.deleted_at IS NULL
AND us.created_at <= ?)
OR EXISTS (SELECT 1
FROM profile AS p
WHERE p.id = us.profile_id
AND p.deleted_at IS NOT NULL
AND p.deleted_at <= ?)
ORDER BY us.created_at ASC
LIMIT ?
FOR UPDATE OF us
SKIP LOCKED")
(def ^:private sql:delete-session-chunks
"DELETE FROM upload_session_chunk
WHERE session_id = ?
RETURNING object_id")
(defn- delete-upload-sessions!
"Purges consumed upload sessions (marked by assemble-chunks), stalled
sessions (never assembled within max-age) and sessions owned by profiles
pending purge. Referenced storage objects are touched so the storage GC
reclaims them with its usual delay; chunk mappings are removed before the
session row (NO ACTION foreign keys)."
[{:keys [::db/conn ::timestamp ::chunk-size ::sto/storage] :as cfg}]
(let [stalled-threshold (ct/minus timestamp {:hours 1})]
(->> (db/plan conn [sql:get-upload-sessions timestamp stalled-threshold timestamp chunk-size]
{:fetch-size 5})
(reduce (fn [total {:keys [id]}]
(l/trc :obj "upload-session" :id (str id))
;; Remove the chunk mappings, marking as touched all
;; related storage objects in a single round-trip.
(doseq [{:keys [object-id]} (db/exec! conn [sql:delete-session-chunks id])]
(some->> object-id (sto/touch-object! storage)))
(let [affected (-> (db/delete! conn :upload-session {:id id})
(db/get-update-count))]
(+ total affected)))
0))))
(def ^:private sql:get-profiles
"SELECT id, photo_id FROM profile
WHERE deleted_at IS NOT NULL
@@ -337,11 +292,7 @@
0)))
(def ^:private deletion-proc-vars
;; NOTE: upload sessions go first: deleting a profile cascades to its
;; sessions, which would hit the upload_session_chunk NO ACTION foreign key
;; while mappings still exist.
[#'delete-upload-sessions!
#'delete-profiles!
[#'delete-profiles!
#'delete-file-media-objects!
#'delete-file-object-thumbnails!
#'delete-file-thumbnails!
@@ -0,0 +1,41 @@
;; 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 SUBSIDIARY SL
(ns app.tasks.upload-session-gc
"A maintenance task that deletes stalled (incomplete) upload sessions.
An upload session is considered stalled when it was created more than
`max-age` ago without being completed (i.e. the session row still
exists because `assemble-chunks` was never called to clean it up).
The default max-age is 1 hour."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[integrant.core :as ig]))
(def ^:private sql:delete-stalled-sessions
"DELETE FROM upload_session
WHERE created_at < ?::timestamptz")
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/expand-key ::handler
[k v]
{k (merge {::max-age (ct/duration {:hours 1})} v)})
(defmethod ig/init-key ::handler
[_ {:keys [::max-age] :as cfg}]
(fn [_]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [threshold (ct/minus (ct/now) max-age)
result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold])
(db/get-update-count))]
(l/debug :hint "task finished" :deleted result)
{:deleted result})))))
@@ -1,76 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.auth-ldap-test
(:require
[app.auth.ldap :as ldap-auth]
[clj-ldap.client :as ldap]
[clojure.test :as t]))
;; --- search-user: filter must be escaped (RED: currently not escaped)
(t/deftest search-user-escapes-email-in-filter
(t/testing "wildcard * is escaped before building LDAP filter"
(let [captured-query (atom nil)
fake-search (fn [_conn _base-dn params]
(reset! captured-query (:filter params))
[])]
(with-redefs [ldap/search fake-search]
(#'ldap-auth/search-user {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"}
"fry*@planetexpress.com"))
;; After fix: * should be escaped as \2a
(t/is (= "(mail=fry\\2a@planetexpress.com)" @captured-query)
"filter must have * escaped per RFC 4515"))))
;; --- retrieve-user: email must come from directory, not client (RED)
(t/deftest retrieve-user-uses-directory-email
(t/testing "returned email is from LDAP directory, not client input"
(let [fake-search (fn [_conn _base-dn _params]
[{:dn "cn=fry,ou=people,dc=planetexpress,dc=com"
:mail "fry@planetexpress.com"
:cn "Philip J. Fry"
:uid "fry"}])
fake-bind? (fn [_conn _dn _password] true)]
(with-redefs [ldap/search fake-search
ldap/bind? fake-bind?]
(let [cfg {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"}
result (#'ldap-auth/retrieve-user cfg {:email "fry*@planetexpress.com" :password "fry"})]
;; After fix: email should be from directory (fry@planetexpress.com)
;; BUG: email is client input (fry*@planetexpress.com)
(t/is (= "fry@planetexpress.com" (:email result))
"email must come from LDAP directory attribute, not client input"))))))
;; --- authenticate: full flow with directory email (RED)
(t/deftest authenticate-returns-directory-email
(t/testing "authenticate returns directory email for profile"
(let [fake-search (fn [_conn _base-dn _params]
[{:dn "cn=amy,ou=people,dc=planetexpress,dc=com"
:mail "amy@planetexpress.com"
:cn "Amy Wong"
:uid "amy"}])
fake-bind? (fn [_conn _dn _password] true)]
(with-redefs [ldap/search fake-search
ldap/bind? fake-bind?
ldap/connect (fn [_cfg] (reify java.lang.AutoCloseable (close [_] nil)))]
(let [cfg {:query "(mail=:username)" :sizelimit 1
:attrs-username "uid" :attrs-email "mail"
:attrs-fullname "cn"
:bind-dn "cn=admin,dc=planetexpress,dc=com"
:bind-password "GoodNewsEveryone"
:host "localhost" :port 10389
:ssl false :tls false
:base-dn "ou=people,dc=planetexpress,dc=com"}
result (ldap-auth/authenticate cfg {:email "*@planetexpress.com" :password "amy"})]
;; After fix: email should be amy@planetexpress.com (directory)
;; BUG: email is *@planetexpress.com (client)
(t/is (= "amy@planetexpress.com" (:email result))
"authenticate must return directory email, not client-supplied wildcard"))))))
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.demo-test
(:require
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.graph-binder-gate-test
"Binder gate for the incremental-sync statement templates.
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.graph-sync-parity-test
"Cold projection and incremental sync are two implementations of one mapping,
@@ -1,30 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.http-client-test
(:require
[app.http.client :as http]
[clojure.test :as t]
[java-http-clj.core :as jhttp]
[mockery.core :refer [with-mocks]]))
(t/deftest send-injects-default-timeout-when-absent
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get :uri "https://example.com/"})
(let [[req _opts] (:call-args @mock)]
(t/is (= http/default-request-timeout (:timeout req)))))))
(t/deftest send-preserves-caller-supplied-timeout
(with-mocks [mock {:target 'java-http-clj.core/send
:return {:status 200 :body ""}}]
(let [client (jhttp/build-client {})]
(http/send! client {:method :get
:uri "https://example.com/"
:timeout 5000})
(let [[req _opts] (:call-args @mock)]
(t/is (= 5000 (:timeout req)))))))
@@ -8,7 +8,6 @@
(:require
[app.common.exceptions :as ex]
[app.config :as cf]
[app.http.client :as http]
[app.media.remote :as media.remote]
[app.setup :as-alias setup]
[app.util.json :as json]
@@ -501,22 +500,6 @@
:headers {}})]
(t/is (= 200 (:status resp))))))))
(t/deftest service-request-puts-configured-timeout-in-request
(t/testing "service-request puts media-processing-service-timeout on the http request"
(let [captured (atom nil)]
(with-redefs [cf/get (th/config-get-mock config-mock)
http/req (fn [_client request _opts]
(reset! captured request)
{:status 200
:body (json-stream {:width 100 :height 100})})]
(media.remote/service-request
(mk-system)
{:method :post
:uri "http://localhost:6065/api/image/info"
:body nil
:headers {}})
(t/is (= 5000 (:timeout @captured)))))))
;; ---------------------------------------------------------------------------
;; Shared key
;; ---------------------------------------------------------------------------
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.passwords-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-demo-test
(:require
+3 -6
View File
@@ -158,8 +158,7 @@
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; processed = 4: the 2 font variants plus the 2 consumed upload sessions
(t/is (= 4 (:processed res)))))
(t/is (= 2 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
@@ -225,8 +224,7 @@
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; processed = 3: the font plus the 2 consumed upload sessions
(t/is (= 3 (:processed res)))))
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
@@ -273,8 +271,7 @@
;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))]
(let [res (th/run-task! :objects-gc {})]
;; processed = 3: the font variant plus the 2 consumed upload sessions
(t/is (= 3 (:processed res)))))
(t/is (= 1 (:processed res)))))
(binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))]
(let [res (th/run-task! :storage-gc-touched {})]
@@ -17,7 +17,6 @@
[app.msgbus :as mbus]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.util.ssrf :as ssrf]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.set :as set]
@@ -1807,14 +1806,13 @@
(t/deftest check-organization-sso-returns-valid-true
(let [organization-id (uuid/random)
out (with-redefs [ssrf/safe-url? (constantly true)
oidc/is-organization-sso-config-valid? (constantly true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"}))]
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
@@ -1829,36 +1827,19 @@
(t/deftest check-organization-sso-passes-issuer-to-validation
(let [organization-id (uuid/random)
out (with-redefs [ssrf/safe-url? (constantly true)
oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com/"}))]
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg sso]
(and (= "test-client" (:client-id sso))
(= "https://idp.example.com/" (:issuer sso))))]
(th/management-command!
{::th/type :check-organization-sso
:organization-id organization-id
:client-id "test-client"
:client-secret "test-secret"
:issuer "https://idp.example.com/"}))]
(t/is (th/success? out))
(t/is (true? (-> out :result :valid)))))
(t/deftest check-organization-sso-returns-valid-false-on-ssrf-blocked-issuer
(t/testing "an SSRF-blocked issuer must not reach the OIDC validation flow"
(let [called? (atom false)
out (with-redefs [oidc/is-organization-sso-config-valid?
(fn [_cfg _sso] (reset! called? true) true)]
(th/management-command!
{::th/type :check-organization-sso
:organization-id (uuid/random)
:client-id "test-client"
:client-secret "test-secret"
:issuer "http://127.0.0.1/idp"}))]
(t/is (th/success? out))
(t/is (false? (-> out :result :valid)))
(t/is (false? @called?)
"OIDC validation should not run when the issuer is SSRF-blocked"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUSH AUDIT EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+2 -320
View File
@@ -7,7 +7,6 @@
(ns backend-tests.rpc-media-test
(:require
[app.common.uuid :as uuid]
[app.db :as db]
[app.http.client :as http]
[app.media :as media]
[app.rpc :as-alias rpc]
@@ -533,7 +532,7 @@
:index 0
:content mfile})
;; First assemble succeeds; session row is marked as consumed afterwards
;; First assemble succeeds; session row is deleted afterwards
(let [out1 (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
@@ -546,7 +545,7 @@
(t/is (= media-id (:id (:result out1)))))
;; Second assemble with the same session-id must fail because the
;; session row has been marked as consumed after the first assembly
;; session row has been deleted after the first assembly
(let [out2 (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
@@ -682,94 +681,6 @@
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-duplicate-then-assemble
;; A rejected duplicate must leave the first chunk intact: upload 0,
;; re-upload 0 (rejected), then assemble succeeds with the original size.
(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})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"
size (alength (first chunks))]
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "after-dupe"
:mtype mtype})]
(t/is (nil? (:error out)))
(let [storage (:app.storage/storage th/*system*)
mobj (sto/get-object storage (:media-id (:result out)))]
(t/is (= size (:size mobj)))))))
(t/deftest chunked-upload-rejected-duplicate-keeps-session-usable
;; Rejecting a duplicate must not poison the session: the remaining
;; distinct indices still accumulate and assemble normally.
(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})
session-id (create-session! prof 2)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 110000)
mtype "image/jpeg"]
(t/is (= 3 (count chunks)))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 1
:content (make-chunk-mfile (nth chunks 1) mtype)})]
(t/is (nil? (:error out))))
;; The mapping table holds exactly the two distinct indices: the
;; rejected duplicate stored nothing.
(let [rows (th/db-exec! ["SELECT chunk_index FROM upload_session_chunk WHERE session_id = ? ORDER BY chunk_index"
session-id])]
(t/is (= [0 1] (mapv :chunk-index rows))))))
(t/deftest chunked-upload-session-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
@@ -807,48 +718,6 @@
(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-consumed-session-frees-quota
;; Consumed sessions must not count against the sessions-per-profile
;; quota: with the limit set to 1, assembling a session frees the slot
;; for a new one.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-upload-sessions-per-profile 1})}]
(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})
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
session-id (create-session! prof 1)
upload-out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error upload-out)))
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype "image/jpeg"})]
(t/is (nil? (:error assemble-out))))
;; the consumed session frees the quota slot
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})]
(t/is (nil? (:error out)))
(t/is (uuid? (:session-id (:result out))))))))
(t/deftest chunked-upload-invalid-total-chunks
;; total-chunks must be at least 1; zero and negative values are rejected
;; with a :validation error.
@@ -898,41 +767,6 @@
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
(t/deftest chunked-upload-chunk-too-large
;; Chunks larger than the configured cap must be rejected with
;; :validation / :chunk-too-large before anything is stored, while a
;; chunk exactly at the cap still uploads fine.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:upload-max-chunk-size 1024})}]
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"]
;; 312043 bytes exceeds the mocked 1024-byte cap: rejected
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-too-large (-> out :error ex-data :code))))
;; Nothing stored for the rejected chunk
(t/is (= 0 (:count (th/db-exec-one! ["SELECT count(*) FROM upload_session_chunk WHERE session_id = ?"
session-id]))))
;; A chunk exactly at the cap still uploads fine
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
(t/is (nil? (:error out)))))))
(t/deftest chunked-upload-sessions-per-profile-quota
;; With the session limit set to 2, creating a third session for the
;; same profile must fail with :restriction / :max-quote-reached.
@@ -954,158 +788,6 @@
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
;; --- upload_session_chunk mapping tests ---
(t/deftest chunked-upload-creates-chunk-mapping
;; Uploading a chunk creates a row in upload_session_chunk pointing to the
;; storage object, and the object itself carries no session metadata.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(let [row (th/db-exec-one! ["select session_id, object_id, chunk_index from upload_session_chunk where session_id = ?"
session-id])]
(t/is (= session-id (:session-id row)))
(t/is (= 0 (:chunk-index row)))
(let [storage (:app.storage/storage th/*system*)
obj (sto/get-object storage (:object-id row))]
(t/is (sto/object? obj))
(t/is (= "upload-session" (-> obj meta :bucket)))
(t/is (nil? (-> obj meta :upload-id)))
(t/is (nil? (-> obj meta :chunk-index)))))))
(t/deftest chunked-upload-duplicate-index-fails
;; Re-uploading an already stored index fails with
;; :validation/:chunk-already-exists and creates no new storage object.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out1 (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out1)))
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
out2 (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out2)))
(t/is (= :validation (-> out2 :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out2 :error ex-data :code)))
(t/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
(t/deftest chunked-upload-to-consumed-session-fails
;; Once assembled, the session is consumed: uploading another chunk fails
;; with :not-found and the session row stays, marked with deleted_at.
(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})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out1 (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out1)))
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype "image/jpeg"})]
(t/is (nil? (:error assemble-out))))
;; chunk mappings stay until objects-gc purges them, session row
;; stays marked as consumed
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
(t/is (some? (:deleted-at (th/db-exec-one! ["select deleted_at from upload_session where id = ?"
session-id]))))
;; uploading to the consumed session fails without creating an object
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(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/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
(defn- sql-state-of
"Runs thunk (a db statement) and returns the SQLState of the raised
SQLException, or nil when no error is raised."
[thunk]
(try
(thunk)
nil
(catch java.sql.SQLException cause
(.getSQLState cause))))
(t/deftest upload-session-chunk-restrict-blocks-direct-deletes
;; With a live mapping row, deleting the storage object or the session
;; directly violates the RESTRICT foreign keys (SQLState 23503).
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(let [object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
session-id]))]
(t/is (= "23503" (sql-state-of #(th/db-exec! ["delete from storage_object where id = ?"
object-id]))))
(t/is (= "23503" (sql-state-of #(th/db-exec! ["delete from upload_session where id = ?"
session-id]))))
;; the profile cannot disappear either while its session is live
;; (profile_id FK is NO ACTION DEFERRABLE; purge goes through
;; objects-gc). The deletion_protection rule is disabled here so the
;; statement reaches the FK check.
(t/is (= "23503" (sql-state-of #(db/transact! th/*pool*
(fn [conn]
(db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"])
(db/exec! conn ["delete from profile where id = ?"
(:id prof)])))))))))
;; --- Clone File Media Object BOLA tests ---
(defn- create-storage-object!
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.rpc-plugins-test
(:require
+3 -141
View File
@@ -292,9 +292,8 @@
{:id (:id result-2)})
;; run the objects gc task for permanent deletion
;; (processed = 2: the consumed upload session plus the font variant)
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(t/is (= 1 (:processed res))))
;; revert touched state to all storage objects
@@ -818,8 +817,8 @@
;; mark all the chunks of this session as pending (simulates rows that
;; were never promoted)
(th/db-exec! ["update storage_object set status = 'pending' where id in (select object_id from upload_session_chunk where session_id = ?)"
session-id])
(th/db-exec! ["update storage_object set status = 'pending' where (metadata->>'~:upload-id') = ?"
(str session-id)])
;; assembling fails because no chunk is visible anymore
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
@@ -831,143 +830,6 @@
:mtype "image/jpeg"})]
(t/is (some? (:error assemble-out))))))
(t/deftest upload-session-stalled-purge-lifecycle
;; Full lifecycle of a stalled session: objects-gc purges the session and
;; its mappings while touching the objects, touched-gc marks them deleted
;; and deleted-gc removes rows and blobs.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
_ (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "chunk"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
session-id (-> (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})
:result :session-id)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
;; backdate the session so it counts as stalled
(th/db-exec! ["update upload_session set created_at = now() - interval '2 hours' where id = ?"
session-id])
;; objects-gc purges session and mappings, touching the objects
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session where id = ?"
session-id]))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"]))))
;; touched-gc marks the orphaned object as deleted
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
(th/run-task! :storage-gc-touched {}))]
(t/is (= 0 (:freeze res)))
(t/is (= 1 (:delete res))))
;; deleted-gc removes the row and the blob (clock past the mark time)
(let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 4}))]
(th/run-task! :storage-gc-deleted {}))]
(t/is (= 1 (:deleted res))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from storage_object"]))))))
(t/deftest upload-session-consumed-purge
;; An assembled session is marked as consumed and objects-gc purges it
;; right away, without waiting for the stalled threshold.
(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})
mfile {:filename "chunk"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
session-id (-> (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})
:result :session-id)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype "image/jpeg"})]
(t/is (nil? (:error assemble-out))))
;; mappings stay, session row stays marked as consumed; objects-gc
;; purges both
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
(t/is (some? (:deleted-at (th/db-exec-one! ["select deleted_at from upload_session where id = ?"
session-id]))))
;; objects-gc purges the consumed session immediately
(let [res (th/run-task! :objects-gc {})]
(t/is (= 1 (:processed res))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session where id = ?"
session-id]))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))))
(t/deftest upload-session-profile-purge
;; Sessions owned by a profile pending purge are drained first, so the
;; profile delete (which cascades to its sessions) never hits the chunk
;; RESTRICT foreign keys.
(let [prof (th/create-profile* 1)
mfile {:filename "chunk"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
session-id (-> (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})
:result :session-id)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
;; soft-delete the profile; the live session is neither consumed nor stalled
(th/db-update! :profile {:deleted-at (ct/now)} {:id (:id prof)})
(th/run-task! :objects-gc {})
;; session and mappings are gone, profile row deletes cleanly
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session where id = ?"
session-id]))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from profile where id = ?"
(:id prof)]))))
;; and the chunk object was touched for the storage GC
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"]))))))
(defn- fake-s3-backend
[]
{::sto/type :s3
-106
View File
@@ -1,106 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { rpcPost, extractCookie } from "./helpers/client.mjs";
async function loginWithLdap(email, password) {
const res = await rpcPost("login-with-ldap", { email, password });
if (res.status !== 200 || res.body.type) {
throw new Error(
`LDAP login failed: ${JSON.stringify(res.body)}`
);
}
const cookie = extractCookie(res.setCookie);
return { profile: res.body, cookie };
}
describe("LDAP injection — T5-N1-03", () => {
it("normal LDAP login works with valid credentials", async () => {
const { profile, cookie } = await loginWithLdap(
"fry@planetexpress.com",
"fry"
);
assert.equal(profile.email, "fry@planetexpress.com");
assert.ok(profile.id, "profile should have id");
assert.ok(cookie, "cookie should be set");
});
it("wildcard injection: *@planetexpress.com must not return client literal as email", async () => {
// ATTACK SCENARIO (from Criptored audit):
// 1. Attacker (amy) sends email="*@planetexpress.com" with her own password
// 2. LDAP filter becomes (mail=*@planetexpress.com) — * is a wildcard
// 3. With sizelimit=1, LDAP returns amy's entry (first match)
// 4. Bind succeeds: amy's DN + amy's password = valid
//
// EXPECTED BEHAVIOR AFTER FIX (two valid outcomes):
// A) If * is escaped: LDAP finds no match → wrong-credentials (injection blocked)
// B) If * matches: profile email must be "amy@planetexpress.com" (directory), not "*@planetexpress.com" (client)
//
// Either outcome is correct — the vulnerability is fixed.
try {
const { profile } = await loginWithLdap("*@planetexpress.com", "amy");
// Outcome B: login succeeded, verify email is from directory
assert.equal(
profile.email,
"amy@planetexpress.com",
"email must come from LDAP directory, not client input"
);
} catch (e) {
// Outcome A: injection blocked — * is escaped, no LDAP match
assert.ok(
e.message.includes("wrong-credentials"),
"wildcard should be rejected or return directory email"
);
}
});
it("identity swap: alternate email must return primary directory email", async () => {
// Professor has two emails in LDAP: professor@ and hubert@.
// Login with hubert@ — the profile email should be the one
// the LDAP directory returns as attrs-email, not what the client typed.
//
// EXPECTED BEHAVIOR AFTER FIX:
// Profile email should be "professor@planetexpress.com" (primary directory email),
// NOT "hubert@planetexpress.com" (client literal).
//
// CURRENT BUG: email is "hubert@planetexpress.com" (client literal) — test FAILS
const { profile, cookie } = await loginWithLdap(
"hubert@planetexpress.com",
"professor"
);
assert.ok(profile.id, "profile should have id");
assert.ok(cookie, "cookie should be set");
// This assertion FAILS with current code (RED) — proves the vulnerability
assert.equal(
profile.email,
"professor@planetexpress.com",
"email must come from LDAP directory, not client input"
);
});
it("wrong password fails", async () => {
try {
await loginWithLdap("fry@planetexpress.com", "wrong-password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(
e.message.includes("LDAP login failed") ||
e.message.includes("wrong-credentials"),
"should fail with wrong credentials"
);
}
});
it("non-existent user fails", async () => {
try {
await loginWithLdap("nobody@planetexpress.com", "password");
assert.fail("should have thrown");
} catch (e) {
assert.ok(
e.message.includes("LDAP login failed") ||
e.message.includes("wrong-credentials"),
"should fail for non-existent user"
);
}
});
});
+87 -44
View File
@@ -6,6 +6,7 @@
(ns app.common.files.changes
(:require
#?(:cljs [app.common.files.validate :as val])
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
@@ -428,7 +429,14 @@
[:set-base-font-size
[:map {:title "ModBaseFontSize"}
[:type [:= :set-base-font-size]]
[:base-font-size :string]]]])
[:base-font-size :string]]]
[:validate-shapes
[:map {:title "ValidateShapesChange"}
[:type [:= :validate-shapes]]
[:page-id ::sm/uuid]
[:shape-ids [:vector ::sm/uuid]]
[:context :string]]]])
(def schema:changes
[:sequential {:gen/max 5 :gen/min 1} schema:change])
@@ -464,7 +472,7 @@
to the processor backend."
nil)
(defmulti process-change (fn [_ change] (:type change)))
(defmulti process-change (fn [_ change _] (:type change)))
(defmulti process-operation (fn [_ op] (:type op)))
;; Changes Processing Impl
@@ -496,22 +504,25 @@
(defn process-changes
([data items]
(process-changes data items true))
(process-changes data items true {}))
([data items verify?]
(process-changes data items verify? {}))
([data items verify? libraries]
;; When verify? false we spec the schema validation. Currently used
;; to make just 1 validation even if the changes are applied twice
(when verify?
(check-changes items))
(binding [*touched-changes* (volatile! #{})]
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
(let [result (reduce #(or (process-change %1 %2 libraries) %1) data items)]
(reduce process-touched-change result @*touched-changes*)))))
;; --- Comment Threads
(defmethod process-change :set-comment-thread-position
[data {:keys [page-id comment-thread-id position frame-id]}]
[data {:keys [page-id comment-thread-id position frame-id]} _]
(d/update-in-when data [:pages-index page-id]
(fn [page]
(if (and position frame-id)
@@ -524,7 +535,7 @@
;; --- Guides
(defmethod process-change :set-guide
[data {:keys [page-id id params]}]
[data {:keys [page-id id params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -540,7 +551,7 @@
;; --- Flows
(defmethod process-change :set-flow
[data {:keys [page-id id params]}]
[data {:keys [page-id id params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -556,7 +567,7 @@
;; --- Grids
(defmethod process-change :set-default-grid
[data {:keys [page-id grid-type params]}]
[data {:keys [page-id grid-type params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -593,7 +604,7 @@
(update state :media-refs into xform media-refs)))
(defmethod process-change :add-obj
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]} _]
;; NOTE: we only perform hard validation on backend
#?(:clj (validate-shape obj page-id))
@@ -628,7 +639,7 @@
objects))
(defmethod process-change :mod-obj
[data {:keys [page-id component-id] :as change}]
[data {:keys [page-id component-id] :as change} _]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] process-operations change)
(d/update-in-when data [:components component-id :objects] process-operations change)))
@@ -658,19 +669,19 @@
objects))
(defmethod process-change :reorder-children
[data {:keys [page-id component-id] :as change}]
[data {:keys [page-id component-id] :as change} _]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] process-children-reordering change)
(d/update-in-when data [:components component-id :objects] process-children-reordering change)))
(defmethod process-change :del-obj
[data {:keys [page-id component-id id ignore-touched]}]
[data {:keys [page-id component-id id ignore-touched]} _]
(if page-id
(d/update-in-when data [:pages-index page-id] ctst/delete-shape id ignore-touched)
(d/update-in-when data [:components component-id] ctst/delete-shape id ignore-touched)))
(defmethod process-change :fix-obj
[data {:keys [page-id component-id id] :as params}]
[data {:keys [page-id component-id id] :as params} _]
(letfn [(fix-container [container]
(case (:fix params :broken-children)
:broken-children (ctst/fix-broken-children container id)
@@ -682,7 +693,7 @@
(d/update-in-when data [:components component-id] fix-container))))
(defmethod process-change :reg-objects
[data {:keys [page-id component-id shapes]}]
[data {:keys [page-id component-id shapes]} _]
;; FIXME: Improve performance
(letfn [(reg-objects [objects]
(let [lookup (d/getf objects)
@@ -734,7 +745,7 @@
(defmethod process-change :mov-objects
;; FIXME: ignore-touched is no longer used, so we can consider it deprecated
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]}]
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]} _]
(letfn [(calculate-invalid-targets [objects shape-id]
(let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
(->> (get-in objects [shape-id :shapes])
@@ -849,7 +860,7 @@
(d/update-in-when data [:components component-id :objects] move-objects))))
(defmethod process-change :add-page
[data {:keys [id name page]}]
[data {:keys [id name page]} _]
(when (and id name page)
(ex/raise :type :conflict
:hint "id+name or page should be provided, never both"))
@@ -859,7 +870,7 @@
(ctpl/add-page data page)))
(defmethod process-change :mod-page
[data {:keys [id] :as params}]
[data {:keys [id] :as params} _]
(d/update-in-when data [:pages-index id]
(fn [page]
(let [name (get params :name)
@@ -889,7 +900,7 @@
(dissoc :pixel-grid-opacity))))))
(defmethod process-change :set-plugin-data
[data {:keys [object-type object-id page-id namespace key value]}]
[data {:keys [object-type object-id page-id namespace key value]} _]
(letfn [(update-fn [data]
(if (some? value)
(assoc-in data [:plugin-data namespace key] value)
@@ -915,83 +926,83 @@
(d/update-in-when data [:components object-id] update-fn))))
(defmethod process-change :del-page
[data {:keys [id]}]
[data {:keys [id]} _]
(ctpl/delete-page data id))
(defmethod process-change :mov-page
[data {:keys [id index]}]
[data {:keys [id index]} _]
(update data :pages d/insert-at-index index [id]))
(defmethod process-change :add-color
[data {:keys [color]}]
[data {:keys [color]} _]
(ctl/add-color data color))
(defmethod process-change :mod-color
[data {:keys [color]}]
[data {:keys [color]} _]
(ctl/set-color data color))
(defmethod process-change :del-color
[data {:keys [id]}]
[data {:keys [id]} _]
(ctl/delete-color data id))
;; -- Media
(defmethod process-change :add-media
[data {:keys [object]}]
[data {:keys [object]} _]
(update data :media assoc (:id object) object))
(defmethod process-change :mod-media
[data {:keys [object]}]
[data {:keys [object]} _]
(d/update-in-when data [:media (:id object)] merge object))
(defmethod process-change :del-media
[data {:keys [id]}]
[data {:keys [id]} _]
(d/update-when data :media dissoc id))
;; -- Components
(defmethod process-change :add-component
[data params]
[data params _]
(ctkl/add-component data params))
(defmethod process-change :mod-component
[data params]
[data params _]
(ctkl/mod-component data params))
(defmethod process-change :del-component
[data {:keys [id skip-undelete? delta]}]
[data {:keys [id skip-undelete? delta]} _]
(ctf/delete-component data id skip-undelete? delta))
(defmethod process-change :restore-component
[data {:keys [id page-id]}]
[data {:keys [id page-id]} _]
(ctf/restore-component data id page-id))
(defmethod process-change :purge-component
[data {:keys [id]}]
[data {:keys [id]} _]
(ctf/purge-component data id))
;; -- Typography
(defmethod process-change :add-typography
[data {:keys [typography]}]
[data {:keys [typography]} _]
(ctyl/add-typography data typography))
(defmethod process-change :mod-typography
[data {:keys [typography]}]
[data {:keys [typography]} _]
(ctyl/update-typography data (:id typography) merge typography))
(defmethod process-change :del-typography
[data {:keys [id]}]
[data {:keys [id]} _]
(ctyl/delete-typography data id))
;; -- Design Tokens
(defmethod process-change :set-tokens-lib
[data {:keys [tokens-lib]}]
[data {:keys [tokens-lib]} _]
(assoc data :tokens-lib tokens-lib))
(defmethod process-change :set-token
[data {:keys [set-id token-id attrs]}]
[data {:keys [set-id token-id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1008,7 +1019,7 @@
(ctob/make-token (merge prev-token attrs)))))))))
(defmethod process-change :set-token-set
[data {:keys [id attrs]}]
[data {:keys [id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1023,7 +1034,7 @@
(ctob/update-set lib' id (fn [_] (ctob/make-token-set attrs))))))))
(defmethod process-change :set-token-theme
[data {:keys [id attrs]}]
[data {:keys [id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1041,35 +1052,67 @@
(ctob/make-token-theme (merge prev-token-theme attrs)))))))))
(defmethod process-change :set-active-token-themes
[data {:keys [theme-paths]}]
[data {:keys [theme-paths]} _]
(update data :tokens-lib #(-> % (ctob/ensure-tokens-lib)
(ctob/set-active-themes theme-paths))))
(defmethod process-change :rename-token-set-group
[data {:keys [set-group-path set-group-fname]}]
[data {:keys [set-group-path set-group-fname]} _]
(update data :tokens-lib (fn [lib]
(-> lib
(ctob/ensure-tokens-lib)
(ctob/rename-set-group set-group-path set-group-fname)))))
(defmethod process-change :move-token-set
[data {:keys [from-path to-path before-path before-group] :as changes}]
[data {:keys [from-path to-path before-path before-group] :as changes} _]
(update data :tokens-lib #(-> %
(ctob/ensure-tokens-lib)
(ctob/move-set from-path to-path before-path before-group))))
(defmethod process-change :move-token-set-group
[data {:keys [from-path to-path before-path before-group]}]
[data {:keys [from-path to-path before-path before-group]} _]
(update data :tokens-lib #(-> %
(ctob/ensure-tokens-lib)
(ctob/move-set-group from-path to-path before-path before-group))))
;; === Design Tokens configuration
;; --- Design Tokens configuration
(defmethod process-change :set-base-font-size
[data {:keys [base-font-size]}]
[data {:keys [base-font-size]} _]
(ctf/set-base-font-size data base-font-size))
;; --- Validate Shapes
#?(:clj
(defmethod process-change :validate-shapes
[data _ _]
data))
#?(:cljs
(defmethod process-change :validate-shapes
[data {:keys [page-id shape-ids context]} libraries]
(if libraries
(println "Validating shapes: \n"
" page-id:" (str page-id) "\n"
" shape-ids:" (str shape-ids) "\n"
" context:" context)
(let [file {:data data :id uuid/zero}
errors (reduce (fn [acc shape-id]
(if-let [page (ctpl/get-page data page-id)]
(let [page-errors (val/validate-shape shape-id file page libraries)]
(if (seq page-errors)
(into acc page-errors)
acc))
acc))
[]
shape-ids)]
(when (seq errors)
(ex/raise :type :validation
:code :referential-integrity
:hint (str "error on validating shapes: " context)
:details errors))
data))
data))
;; === Operations
@@ -1203,7 +1203,6 @@
[changes]
(::page-id (meta changes)))
(defn set-text-content
[changes id content prev-content]
(assert-page-id! changes)
@@ -1224,3 +1223,12 @@
(-> changes
(update :redo-changes conj redo-change)
(update :undo-changes conj undo-change))))
;; Validate Shapes
(defn validate-shapes
[changes page-id shape-ids context]
(update changes :redo-changes conj {:type :validate-shapes
:page-id page-id
:shape-ids (vec shape-ids)
:context context}))
+12 -3
View File
@@ -10,7 +10,6 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.files.variant :as cfv]
[app.common.path-names :as cpn]
[app.common.schema :as sm]
[app.common.types.component :as ctk]
@@ -569,7 +568,17 @@
objects (:objects page)
file-data (:data file)
first-child (get objects (first shapes))
prop-names (cfv/extract-properties-names first-child file-data)]
extract-properties-names
(fn [shape]
;; Get the names of the properties of the shape's component
(->> shape
(#(ctkl/get-component file-data (:component-id %) true))
:variant-properties
(map :name)))
prop-names (extract-properties-names first-child)]
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
@@ -583,7 +592,7 @@
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
child file page
:variant-id shape-id))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(when (not= prop-names (extract-properties-names child))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page
+37 -35
View File
@@ -6,12 +6,15 @@
(ns app.common.files.variant
(:require
[app.common.data.macros :as dm]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctcl]
[app.common.types.components-list :as ctkl]
[app.common.types.variant :as ctv]))
(defn find-variant-components
"Find a list of the components that belongs to this variant-id"
"Find the components that belong to the variant container identified by `variant-id`,
preserving the order defined by the container's shapes.
Example return:
(<component1> <component2> ...)"
([data variant-id]
(let [page-id (->> data
:components
@@ -22,22 +25,24 @@
objects (dm/get-in data [:pages-index page-id :objects])]
(find-variant-components data objects variant-id)))
([data objects variant-id]
(assert (or (uuid? variant-id) (nil? variant-id)))
;; We can't simply filter components, because we need to maintain the order
(->> (dm/get-in objects [variant-id :shapes])
(map #(dm/get-in objects [% :component-id]))
(map #(ctcl/get-component data % true))
reverse)))
(defn extract-properties-names
[shape data]
(->> shape
(#(ctcl/get-component data (:component-id %) true))
:variant-properties
(map :name)))
(let [container (get objects variant-id)]
(if (ctv/variant-container? container)
(->> (:shapes container)
(map #(dm/get-in objects [% :component-id]))
(map #(ctkl/get-component data % true))
reverse)
[]))))
(defn extract-properties-values
"Get a map of properties associated to their possible values"
"Get a map of variant property names to their distinct possible values,
collected from all components that belong to the variant container.
Example return:
[{:name 'Property 1' :value ('Value1' 'Value2')}]"
[data objects variant-id]
(assert (or (uuid? variant-id) (nil? variant-id)))
(->> (find-variant-components data objects variant-id)
(mapcat :variant-properties)
(group-by :name)
@@ -47,9 +52,13 @@
:value (->> v (map :value) distinct)}
mdata))))))
(defn get-variant-mains
[component data]
(assert (ctv/valid-variant-component? component) "expected valid component variant")
(defn- get-variant-mains
"Return the ids of the main instance shapes of the variant this component belongs to,
in the order they appear in the container.
Example return:
[<main-shape-a-id> <main-shape-b-id>]"
[data component]
(when-let [variant-id (:variant-id component)]
(let [page-id (:main-instance-page component)
objects (-> (dm/get-in data [:pages-index page-id])
@@ -57,27 +66,20 @@
(dm/get-in objects [variant-id :shapes]))))
(defn is-secondary-variant?
[component data]
(let [shapes (get-variant-mains component data)]
"Return true if the component is a secondary variant in its variant container.
The primary variant is the last one in the container's children list.
Return false if the component is the primary variant or if it's not part of a variant."
[data component]
(let [shapes (get-variant-mains data component)]
(and (seq shapes)
(not= (:main-instance-id component) (last shapes)))))
(defn get-primary-variant
"Return the main instance of the primary variant (the last one) in the variant container."
[data component]
(let [page-id (:main-instance-page component)
objects (-> (dm/get-in data [:pages-index page-id])
(get :objects))
variant-id (:variant-id component)]
(->> (dm/get-in objects [variant-id :shapes])
(let [page-id (:main-instance-page component)
objects (-> (dm/get-in data [:pages-index page-id])
(get :objects))]
(->> (get-variant-mains data component)
peek
(get objects))))
(defn get-primary-component
[data component-id]
(when-let [component (ctcl/get-component data component-id)]
(if (ctc/is-variant? component)
(->> component
(get-primary-variant data)
:component-id
(ctcl/get-component data))
component)))
+31 -5
View File
@@ -290,8 +290,11 @@
duplicated-parent?
(->> ids-map vals (some #(= % (:parent-id first-shape))))
grid-parent?
(and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
changes
(if (and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
(if grid-parent?
(let [target-cell (-> position meta :cell)
[row column]
@@ -313,7 +316,19 @@
changes
(reduce #(pcb/add-object %1 %2 {:ignore-touched true})
changes
(rest new-shapes))]
(rest new-shapes))
ids-to-validate (cond-> [(:id first-shape)]
grid-parent?
(conj (:parent-id first-shape)))
changes (if (seq ids-to-validate)
(pcb/validate-shapes changes
(:id page)
ids-to-validate
(str "generate-instantiate-component: " component-id
" under parent-id" (or parent-id " root")))
changes)]
[new-shape changes])))
@@ -3123,7 +3138,6 @@
;; we calculate a new one because the components will have created new shapes.
ids-map (into {} (map #(vector % (uuid/next))) all-ids)
;; If there is an alt-duplication we change to root
;; For variants so the copy is made as a child of root
;; This is because inside a variant-container can't be a copy
@@ -3135,7 +3149,6 @@
(assoc :parent-id uuid/zero :frame-id uuid/zero)))
shapes)
changes (-> changes
(pcb/with-page page)
(pcb/with-objects all-objects)
@@ -3165,7 +3178,20 @@
(comp
(filter #(= :add-obj (:type %)))
(map #(vector (:old-id %) (-> % :obj :id))))
(:redo-changes changes))]
(:redo-changes changes))
copied-components
(ctn/get-all-instance-roots (:objects page) ids)
ids-to-validate
(map #(get ids-map % %) copied-components)
changes (if (seq ids-to-validate)
(pcb/validate-shapes changes
(:id page)
ids-to-validate
(cond-> (str "generate-duplicate-changes: " ids)))
changes)]
(-> changes
(generate-duplicate-flows shapes page ids-map)
+29 -5
View File
@@ -75,7 +75,7 @@
(reduce check-shape changes mod-obj-changes)))
(defn generate-update-shapes
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}]
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation? extra-context]}]
(let [changes (reduce
(fn [changes id]
(let [opts {:attrs attrs
@@ -96,7 +96,19 @@
(pcb/reorder-grid-children ids))
(not ignore-touched)
(generate-unapply-tokens objects changed-sub-attr))]
(generate-unapply-tokens objects changed-sub-attr))
page-id (pcb/get-page-id changes)
modified-components (ctn/get-all-instance-roots objects ids)
changes (if (and page-id (seq modified-components))
(pcb/validate-shapes changes
page-id
modified-components
(cond-> (str "generate-update-shapes: " ids " " attrs)
(some? extra-context)
(str " \n -> from " extra-context)))
changes)]
changes))
(defn- generate-update-shape-flags
@@ -248,8 +260,8 @@
page-id (pcb/get-page-id changes)
page (or (pcb/get-page changes)
(ctpl/get-page data page-id))
ids (cfh/clean-loops objects ids)
in-component-copy?
(fn [shape-id]
;; Look for shapes that are inside a component copy, but are
@@ -258,7 +270,7 @@
;; If we want to specifically allow altering the copies, this is
;; a special case, like a component swap, in which case we want
;; to delete the old shape
(let [shape (get objects shape-id)]
(let [shape (get objects shape-id)]
(and (ctn/has-any-copy-parent? objects shape)
(not allow-altering-copies))))
@@ -437,7 +449,19 @@
(into []
(remove #(and (ctsi/has-destination %)
(id-to-delete? (:destination %))))
interactions))))))]
interactions))))))
modified-components (ctn/get-all-instance-roots objects (disj all-parents uuid/zero))
;; There is no need to validate deleted objects. Probably also no need to validate hidden or unmasked objects,
;; but we may think of it
changes (if (seq modified-components)
(pcb/validate-shapes changes
page-id
modified-components
(str "generate-delete-shapes: " ids))
changes)]
[all-parents changes])))
@@ -172,10 +172,10 @@
new-props (- min-props
(+ (count props)
(if add-name? 1 0)))
props (ctv/add-new-props props (repeat new-props ""))]
props (ctv/add-new-properties props (repeat new-props ""))]
(if add-name?
(ctv/add-new-prop props (:name component))
(ctv/add-new-property props (:name component))
props)))
(defn- create-new-properties-from-non-variant
+1 -9
View File
@@ -72,13 +72,6 @@
[:map {:title "PlainColorAttrs"}
[:color schema:hex-color]])
(def schema:image-transform
[:map {:title "ImageTransform" :closed true}
[:x {:optional true} ::sm/safe-number]
[:y {:optional true} ::sm/safe-number]
[:width {:optional true} ::sm/safe-number]
[:height {:optional true} ::sm/safe-number]])
(def schema:image
[:map {:title "ImageColor" :closed true}
[:width [::sm/int {:min 0 :gen/gen sg/int}]]
@@ -86,8 +79,7 @@
[:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text]
[:id ::sm/uuid]
[:name {:optional true} ::sm/text]
[:keep-aspect-ratio {:optional true} :boolean]
[:transform {:optional true} schema:image-transform]])
[:keep-aspect-ratio {:optional true} :boolean]])
(def image-attrs
"A set of attrs that corresponds to image data type"
@@ -216,6 +216,41 @@
:else
(get-instance-root objects (get objects (:parent-id shape)))))
(defn get-all-instance-roots
"Given a list of shape ids and an objects tree, returns a set with the ids of
all instance roots that are at, above or below any of the shapes identified by
the given list. An instance root is a shape that has :component-root set to
true (checked by ctk/instance-root?). There is at most one instance root in
any subtree rooted at an instance root, so the downward search stops at the
first instance root found in each branch. Uses a visited set to avoid
reprocessing the same shapes."
[objects shape-ids]
(let [visited (atom #{})
result (atom #{})]
(letfn [(search-up [shape-id]
(when-not (contains? @visited shape-id)
(swap! visited conj shape-id)
(let [shape (get objects shape-id)]
(when-not (nil? shape)
(if (ctk/instance-root? shape)
(swap! result conj (:id shape))
(when-not (cfh/root? shape)
(when-let [parent-id (:parent-id shape)]
(search-up parent-id))))))))
(search-down [shape-id]
(when-not (contains? @visited shape-id)
(swap! visited conj shape-id)
(let [shape (get objects shape-id)]
(when-not (nil? shape)
(if (ctk/instance-root? shape)
(swap! result conj (:id shape))
(doseq [child-id (:shapes shape)]
(search-down child-id)))))))]
(doseq [shape-id shape-ids]
(search-up shape-id)
(search-down shape-id))
@result)))
(defn find-component-main
"If the shape is a component main instance or is inside one, return that instance.
Uses an iterative loop with cycle detection to prevent stack overflow on circular
+27 -49
View File
@@ -119,15 +119,12 @@
(defn write-image-fill
[offset buffer opacity image]
(let [image-id (get image :id)
image-width (get image :width)
image-height (get image :height)
alpha (mth/floor (* opacity 0xff))
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
transform (get image :transform)
has-transform? (some? transform)
transform-flag (if has-transform? 0x02 0x00)
flags (bit-or keep-aspect-ratio transform-flag)]
(let [image-id (get image :id)
image-width (get image :width)
image-height (get image :height)
alpha (mth/floor (* opacity 0xff))
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
flags (bit-or keep-aspect-ratio 0x00)]
(buf/write-byte buffer (+ offset 0) 0x03)
(buf/write-uuid buffer (+ offset 4) image-id)
(buf/write-byte buffer (+ offset 20) alpha)
@@ -135,17 +132,6 @@
(buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use)
(buf/write-int buffer (+ offset 24) image-width)
(buf/write-int buffer (+ offset 28) image-height)
(if has-transform?
(do
(buf/write-float buffer (+ offset 32) (double (get transform :x 0.0)))
(buf/write-float buffer (+ offset 36) (double (get transform :y 0.0)))
(buf/write-float buffer (+ offset 40) (double (get transform :width 1.0)))
(buf/write-float buffer (+ offset 44) (double (get transform :height 1.0))))
(do
(buf/write-float buffer (+ offset 32) 0.0)
(buf/write-float buffer (+ offset 36) 0.0)
(buf/write-float buffer (+ offset 40) 1.0)
(buf/write-float buffer (+ offset 44) 1.0)))
(+ offset FILL-U8-SIZE)))
(defn- write-metadata
@@ -222,36 +208,28 @@
:type type}})
3 ;; image fill
(let [id (buf/read-uuid dbuffer (+ doffset 4))
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
opacity (mth/precision (/ alpha 0xff) 2)
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
ratio (not (zero? (bit-and flags 0x01)))
has-tf (not (zero? (bit-and flags 0x02)))
width (buf/read-int dbuffer (+ doffset 24))
height (buf/read-int dbuffer (+ doffset 28))
transform (when has-tf
{:x (buf/read-float dbuffer (+ doffset 32))
:y (buf/read-float dbuffer (+ doffset 36))
:width (buf/read-float dbuffer (+ doffset 40))
:height (buf/read-float dbuffer (+ doffset 44))})
mtype (buf/read-short mbuffer (+ moffset 2))
mtype (case mtype
0x01 "image/jpeg"
0x02 "image/png"
0x03 "image/gif"
0x04 "image/webp"
0x05 "image/svg+xml")]
(let [id (buf/read-uuid dbuffer (+ doffset 4))
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
opacity (mth/precision (/ alpha 0xff) 2)
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
ratio (boolean (bit-and flags 0x01))
width (buf/read-int dbuffer (+ doffset 24))
height (buf/read-int dbuffer (+ doffset 28))
mtype (buf/read-short mbuffer (+ moffset 2))
mtype (case mtype
0x01 "image/jpeg"
0x02 "image/png"
0x03 "image/gif"
0x04 "image/webp"
0x05 "image/svg+xml")]
{:fill-opacity opacity
:fill-image (cond-> {:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio ratio
;; FIXME: we are not encodign the name, looks useless
:name "sample"}
(some? transform)
(assoc :transform transform))}))]
:fill-image {:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio ratio
;; FIXME: we are not encodign the name, looks useless
:name "sample"}}))]
(if refs?
(let [ref-file (buf/read-uuid mbuffer (+ moffset 4))
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.path.fit
"Curve fitting helpers."
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.path.selection
"Transforms selected path nodes and handlers."
+1 -17
View File
@@ -272,20 +272,6 @@
-1))))
items))))
(defn- clipped-by-ancestor?
"Checks whether position falls outside the visible (clipped) bounds of
some ancestor frame with clip content enabled. Used so that a nested
frame that extends beyond a clipping ancestor's own bounds is never
considered hit/reachable in the invisible, clipped-away region."
[objects shape position]
(->> (cfh/get-parent-ids objects (dm/get-prop shape :id))
(keep (d/getf objects))
(some (fn [ancestor]
(and (not= (dm/get-prop ancestor :id) uuid/zero)
^boolean (cfh/frame-shape? ancestor)
(not (:show-content ancestor))
(not ^boolean (gsh/has-point? ancestor position)))))))
(defn get-frame-by-position
([objects position]
(get-frame-by-position objects position nil))
@@ -301,7 +287,6 @@
validator (or (get options :validator) #(-> true))]
(or (d/seek #(and ^boolean (some? position)
^boolean (gsh/has-point? % position)
^boolean (not (clipped-by-ancestor? objects % position))
^boolean (validator %))
frames)
(get objects uuid/zero)))))
@@ -317,8 +302,7 @@
([objects position options]
(->> (get-frames objects options)
(filter #(and ^boolean (some? position)
^boolean (gsh/has-point? % position)
^boolean (not (clipped-by-ancestor? objects % position))))
^boolean (gsh/has-point? % position)))
(sort-z-index-objects objects))))
(defn top-nested-frame
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.types.tokens-status
(:require
+266 -93
View File
@@ -27,6 +27,9 @@
[:variant-id {:optional true} ::sm/uuid]
[:variant-properties {:optional true} [:vector schema:variant-property]]])
(def valid-variant-component?
(sm/check-fn schema:variant-component))
(def schema:variant-shape
"The root shape of the main instance of a variant component"
[:map
@@ -34,14 +37,17 @@
[:variant-name {:optional true} :string]
[:variant-error {:optional true} :string]])
(def valid-variant-shape?
(sm/check-fn schema:variant-shape))
(def schema:variant-container
"Is a board that contains all variant components of a variant set,
for grouping them visually in the workspace"
[:map
[:is-variant-container {:optional true} :boolean]])
(def valid-variant-component?
(sm/check-fn schema:variant-component))
(def valid-variant-container?
(sm/check-fn schema:variant-container))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -50,17 +56,41 @@
(def property-max-length 60)
(def value-prefix "Value ")
(defn variant-component?
[component]
(some? (:variant-id component)))
(defn variant-shape?
[shape]
(some? (:variant-id shape)))
(defn variant-container?
[shape]
(some? (:is-variant-container shape)))
(defn properties-to-name
"Transform the properties into a name, with the values separated by comma"
"Transform the properties into a name, with the values separated by comma, excluding the empty ones.
Example:
[{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}] -> 'Button, Primary'"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(->> properties
(map :value)
(remove str/empty?)
(str/join ", ")))
(defn next-property-number
"Returns the next property number, to avoid duplicates on the property names"
"Returns the next property number, to avoid duplicates on the property names.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 3' :value 'y'}] -> 4"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(let [numbers (keep
#(some->> (:name %) (re-find property-regex) second d/parse-integer)
properties)
@@ -69,38 +99,70 @@
0)]
(inc (max max-num (count properties)))))
(defn add-new-prop
"Adds a new property with generated name and provided value to the existing props list."
[props value]
(conj props {:name (str property-prefix (next-property-number props))
:value value}))
(defn add-new-property
"Adds a new property with generated name and provided value to the existing properties list.
(defn add-new-props
"Adds new properties with generated names and provided values to the existing props list."
[props values]
(let [next-prop-num (next-property-number props)
Example:
[{:name 'Property 1' :value 'x'}] 'y' -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]"
[properties value]
(assert (or (sequential? properties) (nil? properties)))
(assert (or (string? value) (nil? value)))
(conj properties {:name (str property-prefix (next-property-number properties))
:value value}))
(defn add-new-properties
"Adds new properties with generated names and provided values to the existing properties list.
Example:
[{:name 'Property 1' :value 'x'}] ['a' 'b'] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'a'}
{:name 'Property 3' :value 'b'}]"
[properties values]
(assert (or (sequential? properties) (nil? properties)))
(assert (or (sequential? values) (nil? values)))
(let [next-prop-num (next-property-number properties)
xf (map-indexed (fn [i v]
{:name (str property-prefix (+ next-prop-num i))
:value v}))]
(into props xf values)))
(into properties xf values)))
(defn path-to-properties
"From a list of properties and a name with path, assign each token of the
path as value of a different property"
path as value of a different property. It can add blank properties if
necessary, until the min-properties number is reached.
Example with min-properties=4:
'Button / Primary / Hover' -> [{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}
{:name 'Property 3' :value 'Hover'}
{:name 'Property 4' :value ''}]"
([path properties]
(path-to-properties path properties 0))
([path properties min-props]
([path properties min-properties]
(assert (or (string? path) (nil? path)))
(assert (or (sequential? properties) (nil? properties)))
(assert (int? min-properties))
(let [cpath (cpn/split-path path)
total-props (max (count cpath) min-props)
total-properties (max (count cpath) min-properties)
assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range))
;; Add empty strings to the end of cpath to reach the minimum number of properties
cpath (take total-props (concat cpath (repeat "")))
cpath (take total-properties (concat cpath (repeat "")))
remaining (drop (count properties) cpath)]
(add-new-props assigned remaining))))
(add-new-properties assigned remaining))))
(defn properties-map->formula
"Transforms a map of properties to a formula of properties omitting the empty ones"
"Transforms a map of properties to a formula of properties omitting the empty ones.
Example:
[{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}] -> 'Property 1=Button, Property 2=Primary'"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(->> properties
(keep (fn [{:keys [name value]}]
(when (not (str/blank? value))
@@ -108,9 +170,15 @@
(str/join ", ")))
(defn properties-formula->map
"Transforms a formula of properties to a map of properties"
[s]
(->> (str/split s ",")
"Transforms a formula of properties to a map of properties.
Example:
'Property 1=Button, Property 2=Primary' -> [{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}]"
[formula]
(assert (or (string? formula) (nil? formula)))
(->> (str/split formula ",")
(mapv #(str/split % "=" 2))
(filter (fn [[_ v]] (not (str/blank? v))))
(mapv (fn [[k v]]
@@ -118,9 +186,15 @@
:value (str/trim v)}))))
(defn valid-properties-formula?
"Checks if a formula is valid"
[s]
(->> (str/split s ",")
"Checks if a formula is valid.
Example:
'Property 1=Button, Property 2=Primary' -> true
'Property 1=Button, Property 2' -> false"
[formula]
(assert (or (string? formula) (nil? formula)))
(->> (str/split formula ",")
(mapv #(str/split % "=" 2))
(every? #(and (= 2 (count %))
(not (str/blank? (first %)))
@@ -128,22 +202,47 @@
(< (count (second %)) property-max-length)))))
(defn find-properties-to-remove
"Compares two property maps to find which properties should be removed"
[prev-props upd-props]
(let [upd-names (set (map :name upd-props))]
(filterv #(not (contains? upd-names (:name %))) prev-props)))
"Compares two property maps to find which properties should be removed.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'x'}] -> [{:name 'Property 2' :value 'y'}]"
[prev-properties upd-properties]
(assert (or (sequential? prev-properties) (nil? prev-properties)))
(assert (or (sequential? upd-properties) (nil? upd-properties)))
(let [upd-names (set (map :name upd-properties))]
(filterv #(not (contains? upd-names (:name %))) prev-properties)))
(defn find-properties-to-update
"Compares two property maps to find which properties should be updated"
[prev-props upd-props]
"Compares two property maps to find which properties should be updated.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'new-x'}
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 1' :value 'new-x'}]"
[prev-properties upd-properties]
(assert (or (sequential? prev-properties) (nil? prev-properties)))
(assert (or (sequential? upd-properties) (nil? upd-properties)))
(filterv #(some (fn [prop] (and (= (:name %) (:name prop))
(not= (:value %) (:value prop)))) prev-props) upd-props))
(not= (:value %) (:value prop)))) prev-properties) upd-properties))
(defn find-properties-to-add
"Compares two property maps to find which properties should be added"
[prev-props upd-props]
(let [prev-names (set (map :name prev-props))]
(filterv #(not (contains? prev-names (:name %))) upd-props)))
"Compares two property maps to find which properties should be added.
Example:
[{:name 'Property 1' :value 'x'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 2' :value 'y'}]"
[prev-properties upd-properties]
(assert (or (sequential? prev-properties) (nil? prev-properties)))
(assert (or (sequential? upd-properties) (nil? upd-properties)))
(let [prev-names (set (map :name prev-properties))]
(filterv #(not (contains? prev-names (:name %))) upd-properties)))
(defn- split-base-name-and-number
"Extract the number in parentheses from an item, if present, and return both the base name and the number"
@@ -165,8 +264,15 @@
(defn update-number-in-repeated-item
"Add, keep or update a number in parentheses for a given item, if necessary, depending on the items
already present in a list, to avoid repetitions"
already present in a list, to avoid repetitions.
Example:
['Property'] 'Property' -> 'Property (1)'
['Property' 'Property (1)'] 'Property' -> 'Property (2)'"
[items item]
(assert (or (sequential? items) (nil? items)))
(assert (or (string? item) (nil? item)))
(let [names (group-numbers-by-base-name items)
[base num] (split-base-name-and-number item)
nums-taken (get names base #{})]
@@ -176,25 +282,46 @@
(str base (when (pos? n) (str " (" n ")")))))))
(defn update-number-in-repeated-prop-names
"Add, keep or update a number for each prop name depending on the previous ones"
[props]
(->> props
"Add, keep or update a number for each prop name depending on the previous ones.
Example:
[{:name 'Property' :value 'x'}
{:name 'Property' :value 'y'}] -> [{:name 'Property' :value 'x'}
{:name 'Property (1)' :value 'y'}]"
[properties]
(assert (or (sequential? properties) (nil? properties)))
(->> properties
(reduce (fn [acc prop]
(conj acc {:name (update-number-in-repeated-item (mapv :name acc) (:name prop))
:value (:value prop)}))
[])))
(defn find-index-for-property-name
"Finds the index of a name in a property map"
[props name]
"Finds the index of a name in a property map.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}] 'Property 2' -> 1"
[properties name]
(assert (or (sequential? properties) (nil? properties)))
(assert (or (string? name) (nil? name)))
(some (fn [[idx prop]]
(when (= (:name prop) name)
idx))
(map-indexed vector props)))
(map-indexed vector properties)))
(defn remove-prefix
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name"
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name.
Example:
'Button / Primary' 'Button' -> 'Primary'
'Button / Primary' 'Other' -> 'Button / Primary'"
[name prefix]
(assert (or (string? name) (nil? name)))
(assert (or (string? prefix) (nil? prefix)))
(let [long-name (str prefix " / ")]
(cond
(str/starts-with? name long-name)
@@ -210,22 +337,22 @@
(map :name))
(defn- matching-indices
[props1 props2]
(let [names-in-p2 (into #{} xf:map-name props2)
[properties1 properties2]
(let [names-in-p2 (into #{} xf:map-name properties2)
xform (comp
(map-indexed (fn [index {:keys [name]}]
(when (contains? names-in-p2 name)
index)))
(filter some?))]
(into #{} xform props1)))
(into #{} xform properties1)))
(defn- find-index-by-name
"Returns the index of the first item in props with the given name, or nil if not found."
[name props]
"Returns the index of the first item in properties with the given name, or nil if not found."
[name properties]
(some (fn [[idx item]]
(when (= (:name item) name)
idx))
(map-indexed vector props)))
(map-indexed vector properties)))
(defn- next-valid-position
"Returns the first non-negative integer not present in the used-pos set."
@@ -236,42 +363,64 @@
p)))
(defn- find-position
"Returns the index of the property with the given name in `props`,
"Returns the index of the property with the given name in `properties`,
or the next available index not in `used-pos` if not found."
[name props used-pos]
(or (find-index-by-name name props)
[name properties used-pos]
(or (find-index-by-name name properties)
(next-valid-position used-pos)))
(defn merge-properties
"Merges props2 into props1 with the following rules:
- For each property p2 in props2:
"Merges properties2 into properties1 with the following rules:
- For each property p2 in properties2:
- Skip it if its value is empty.
- If props1 contains a property with the same name, update its value with that of p2.
- Otherwise, assign p2's value to the first unused property in props1. A property is considered used if:
- Its name exists in both props1 and props2, or
- If properties1 contains a property with the same name, update its value with that of p2.
- Otherwise, assign p2's value to the first unused property in properties1. A property is considered used if:
- Its name exists in both properties1 and properties2, or
- Its value has already been updated during the merge.
- If no unused properties are available in props1, append a new property with a default name and p2's value."
[props1 props2]
(let [props2 (remove #(str/empty? (:value %)) props2)]
- If no unused properties are available in properties1, append a new property with a default name and p2's value.
Example:
[{:name 'Property 1' :value 'a'}
{:name 'Property 2' :value 'b'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}
{:name 'Property 3' :value 'z'}] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}
{:name 'Property 3' :value 'z'}]"
[properties1 properties2]
(assert (or (sequential? properties1) (nil? properties1)))
(assert (or (sequential? properties2) (nil? properties2)))
(let [properties2 (remove #(str/empty? (:value %)) properties2)]
(-> (reduce
(fn [{:keys [props used-pos]} prop]
(let [pos (find-position (:name prop) props used-pos)
(fn [{:keys [properties used-pos]} prop]
(let [pos (find-position (:name prop) properties used-pos)
used-pos (conj used-pos pos)]
(if (< pos (count props))
{:props (assoc-in (vec props) [pos :value] (:value prop)) :used-pos used-pos}
{:props (add-new-prop props (:value prop)) :used-pos used-pos})))
{:props (vec props1) :used-pos (matching-indices props1 props2)}
props2)
:props)))
(if (< pos (count properties))
{:properties (assoc-in (vec properties) [pos :value] (:value prop)) :used-pos used-pos}
{:properties (add-new-property properties (:value prop)) :used-pos used-pos})))
{:properties (vec properties1) :used-pos (matching-indices properties1 properties2)}
properties2)
:properties)))
(defn compare-properties
"Compares vectors of properties keeping the value if it is the same for all
or setting a custom value where their values do not coincide"
([props-list]
(compare-properties props-list nil))
or setting a custom value where their values do not coincide.
([props-list distinct-mark]
(let [grouped (group-by :name (apply concat props-list))
Example:
[[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'z'}]] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value nil}]"
([properties-list]
(compare-properties properties-list nil))
([properties-list distinct-mark]
(assert (or (sequential? properties-list) (nil? properties-list)))
(assert (or (string? distinct-mark) (nil? distinct-mark)))
(let [grouped (group-by :name (apply concat properties-list))
check-values (fn [values]
(let [vals (map :value values)]
(if (apply = vals)
@@ -281,33 +430,37 @@
{:name name :value (check-values values)})
grouped))))
(defn same-variant?
"Determines if all elements belong to the same variant"
[components]
(let [variant-ids (distinct (map :variant-id components))
not-blank? (complement str/blank?)]
(and
(= 1 (count variant-ids))
(not-blank? (first variant-ids)))))
(defn properties-distance
"Computes a weighted distance between two property lists `properties1` and `properties2`.
Latter properties weight less that previous ones.
(defn distance
"Computes a weighted distance between two property lists `props1` and `props2`.
Latter properties weight less that previous ones"
[props1 props2]
(let [total-num-props (count props1)
Example:
[{:name 'type' :value 'primary'}
{:name 'status' :value 'default'}]
[{:name 'type' :value 'primary'}
{:name 'status' :value 'hover'}] -> 1.0"
[properties1 properties2]
(assert (or (sequential? properties1) (nil? properties1)))
(assert (or (sequential? properties2) (nil? properties2)))
(let [total-num-properties (count properties1)
xform (map-indexed
(fn [idx [p1 p2]]
(if (not= p1 p2)
(math/pow 2 (- total-num-props idx))
(math/pow 2 (- total-num-properties idx))
0)))]
(transduce
xform
+
(map vector props1 props2))))
(map vector properties1 properties2))))
(defn variant-name-to-name
"Transforms a variant-name (its properties values) into a standard name:
the real name of the shape joined by the properties values separated by '/'"
the real name of the shape joined by the properties values separated by '/'.
Example:
{:name 'Button' :variant-name 'Primary, Hover'} -> 'Button / Primary / Hover'"
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
@@ -317,8 +470,13 @@
["true" "false"]])
(defn find-boolean-pair
"Given a vector, return a map that contains the boolean equivalency if the values match
with any of the boolean pairs. Returns nil if none match."
"Given a collection, return a map that contains the boolean equivalency if the values match
with any of the boolean pairs. Returns nil if none match.
Example:
['on' 'off'] -> {'on' true 'off' false}
['foo' 'bar'] -> nil"
[[a b :as v]]
(let [a' (-> a str/trim str/lower)
b' (-> b str/trim str/lower)]
@@ -330,3 +488,18 @@
(= a' f)) {b true a false}
:else nil))
boolean-pairs))))
(defn same-variant?
"Determines if all elements belong to the same variant.
Example:
[{:variant-id 'abc'} {:variant-id 'abc'}] -> true
[{:variant-id 'abc'} {:variant-id 'def'}] -> false"
[components]
(assert (or (sequential? components) (nil? components)))
(let [variant-ids (distinct (map :variant-id components))
not-blank? (complement str/blank?)]
(and
(= 1 (count variant-ids))
(not-blank? (first variant-ids)))))
@@ -0,0 +1,165 @@
;; 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 SUBSIDIARY SL
(ns common-tests.files.variant-test
(:require
[app.common.files.variant :as fv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.variants :as thv]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ============================================================
;; find-variant-components
;; ============================================================
(t/deftest find-variant-components-empty
(let [file (thf/sample-file :file1)
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/find-variant-components data (uuid/next))
[]))
(t/is (= (fv/find-variant-components data objects (uuid/next))
[]))))
(t/deftest find-variant-components-non-variant
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :s01))
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/find-variant-components data (thi/id :m01))
[]))
(t/is (= (fv/find-variant-components data objects (thi/id :m01))
[]))))
(t/deftest find-variant-components-normal
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/find-variant-components data objects (thi/id :v01))]
(t/is (= (count result) 2))
(t/is (every? #(contains? % :id) result))
(t/is (every? #(contains? % :variant-id) result))))
(t/deftest find-variant-components-single-variant
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/find-variant-components data objects (thi/id :v01))]
;; Verify the order is maintained (reversed from shapes order)
(t/is (= (:variant-id (first result)) (thi/id :v01)))
(t/is (= (:variant-id (second result)) (thi/id :v01)))))
;; ============================================================
;; extract-properties-values
;; ============================================================
(t/deftest extract-properties-values-empty
(let [file (thf/sample-file :file1)
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/extract-properties-values data objects (uuid/next))
[]))))
(t/deftest extract-properties-values-non-variant
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :s01))
data (:data file)
page (thf/current-page file)
objects (:objects page)]
(t/is (= (fv/extract-properties-values data objects (thi/id :m01))
[]))))
(t/deftest extract-properties-values-normal
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/extract-properties-values data objects (thi/id :v01))]
(t/is (seq result))
(t/is (every? #(contains? % :name) result))
(t/is (every? #(contains? % :value) result))
(t/is (= (:name (first result)) "Property 1"))
(t/is (= (set (:value (first result))) #{"Value1" "Value2"}))))
(t/deftest extract-properties-values-two-properties
(let [file (-> (thf/sample-file :file1)
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02))
data (:data file)
page (thf/current-page file)
objects (:objects page)
result (fv/extract-properties-values data objects (thi/id :v01))]
(t/is (= (count result) 2))
(t/is (= (set (map :name result)) #{"Property 1" "Property 2"}))))
;; ============================================================
;; is-secondary-variant?
;; ============================================================
(t/deftest is-secondary-variant-primary
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
component (thc/get-component file :c01)]
(t/is (not (fv/is-secondary-variant? data component)))))
(t/deftest is-secondary-variant-secondary
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
component (thc/get-component file :c02)]
(t/is (fv/is-secondary-variant? data component))))
(t/deftest is-secondary-variant-not-variant
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :s01))
data (:data file)
component (thc/get-component file :c01)]
(t/is (not (fv/is-secondary-variant? data component)))))
(t/deftest is-secondary-variant-no-shapes
(let [file (thf/sample-file :file1)
data (:data file)
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
(t/is (not (fv/is-secondary-variant? data component)))))
;; ============================================================
;; get-primary-variant
;; ============================================================
(t/deftest get-primary-variant-nil
(let [file (thf/sample-file :file1)
data (:data file)]
(t/is (nil? (fv/get-primary-variant data nil)))))
(t/deftest get-primary-variant-empty
(let [file (thf/sample-file :file1)
data (:data file)
component {:id :comp :variant-id (thi/id :file1) :main-instance-page (thi/id :file1)}]
(t/is (nil? (fv/get-primary-variant data component)))))
(t/deftest get-primary-variant-normal
(let [file (-> (thf/sample-file :file1)
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
data (:data file)
component (thc/get-component file :c01)
result (fv/get-primary-variant data component)]
(t/is (some? result))
(t/is (contains? result :id))
(t/is (contains? result :component-id))))
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.files-migrations-0026-test
(:require
@@ -1,275 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-image-bounds-resize-test
(:require
#?(:clj [clojure.test :refer [deftest is testing]]
:cljs [cljs.test :refer-macros [deftest is testing]])
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.color :as clr]
[app.common.types.fills :as fills]
[app.common.types.fills.impl :as fills.impl]
[app.common.uuid :as uuid]))
(deftest test-image-transform-schema
(testing "validates image with transform"
(let [img {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true
:transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}]
(is (sm/validate clr/schema:image img))))
(testing "validates image without transform"
(let [img {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true}]
(is (sm/validate clr/schema:image img))))
(testing "validates fill with image transform"
(let [fill {:fill-opacity 0.8
:fill-image {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true
:transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}]
(is (sm/validate fills/schema:fill fill)))))
(deftest test-image-fill-buffer-roundtrip
(testing "roundtrip image fill without transform"
(let [fill-vec [{:fill-opacity 0.9
:fill-image {:id (uuid/custom 1)
:width 800
:height 600
:mtype "image/jpeg"
:keep-aspect-ratio true
:name "sample"}}]
coerced (fills/from-plain fill-vec)
plain (into [] coerced)]
(is (= 1 (count plain)))
(is (= 0.9 (:fill-opacity (first plain))))
(is (= 800 (-> plain first :fill-image :width)))
(is (= 600 (-> plain first :fill-image :height)))
(is (true? (-> plain first :fill-image :keep-aspect-ratio)))
(is (nil? (-> plain first :fill-image :transform)))))
(testing "roundtrip image fill with transform"
(let [fill-vec [{:fill-opacity 0.75
:fill-image {:id (uuid/custom 2)
:width 1920
:height 1080
:mtype "image/webp"
:keep-aspect-ratio false
:name "sample"
:transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}]
coerced (fills/from-plain fill-vec)
plain (into [] coerced)
tf (-> plain first :fill-image :transform)]
(is (= 1 (count plain)))
(is (= 0.75 (:fill-opacity (first plain))))
(is (= 1920 (-> plain first :fill-image :width)))
(is (= 1080 (-> plain first :fill-image :height)))
(is (false? (-> plain first :fill-image :keep-aspect-ratio)))
(is (some? tf))
(is (mth/close? 0.25 (double (:x tf))))
(is (mth/close? -0.15 (double (:y tf))))
(is (mth/close? 1.5 (double (:width tf))))
(is (mth/close? 2.0 (double (:height tf)))))))
(defn compute-bounds-resize-transform
"Mathematical model for independent image bounds resizing"
[{:keys [width height handler center? sx sy transform]}]
(let [w-new (* width sx)
h-new (* height sy)
[dx dy] (if ^boolean center?
[(/ (* width (- 1.0 sx)) 2.0)
(/ (* height (- 1.0 sy)) 2.0)]
[(case handler
(:left :bottom-left :top-left) (* width (- 1.0 sx))
0.0)
(case handler
(:top :top-left :top-right) (* height (- 1.0 sy))
0.0)])
nx0 (get transform :x 0.0)
ny0 (get transform :y 0.0)
nw0 (get transform :width 1.0)
nh0 (get transform :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
{:transform {:x nx' :y ny' :width nw' :height nh'}
:rendered-pixel-rect {:x (* nx' w-new)
:y (* ny' h-new)
:width (* nw' w-new)
:height (* nh' h-new)}}))
(deftest test-handle-anchoring-mathematics
(testing "Right handle crop (shrinking width to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content remains 200x100 starting at (0, 0)
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Left handle crop (shrinking width to 50% from left)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!)
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))
(testing "Top handle crop (shrinking height to 50% from top)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 1.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!)
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Top-Left handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Center resize (Alt modifier)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})]
(is (mth/close? -0.5 (-> res :transform :x)))
(is (mth/close? -0.5 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -25.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Bottom handle crop (shrinking height to 50% from bottom)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 1.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Top-Right handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Bottom-Left handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Expanding bounds beyond original size (empty space exposure)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 0.5 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))))
(deftest test-sequential-resize-operations
(testing "Sequential crops: crop right then crop left"
;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1}
;; Step 1: Crop right handle from 200 to 150 (sx = 0.75)
(let [step1 (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0})
tf1 (:transform step1)]
(is (mth/close? 0.0 (:x tf1)))
(is (mth/close? (/ 1.0 0.75) (:width tf1)))
;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3)
(let [step2 (compute-bounds-resize-transform
{:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1})
tf2 (:transform step2)]
;; The final 100x100 container has bitmap with width 200px
(is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width)))
;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px
(is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x))))))
(testing "Bounds resize followed by standard proportional scaling"
;; Step 1: Bounds resize crops width from 200 to 100
(let [step1 (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})
tf1 (:transform step1)]
(is (mth/close? 2.0 (:width tf1)))
(is (mth/close? 1.0 (:height tf1)))
;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x)
;; During standard scale, normalized transform tf1 is kept constant!
(let [scaled-w (* 100.0 2.0)
scaled-h (* 100.0 2.0)
rendered-w (* (:width tf1) scaled-w)
rendered-h (* (:height tf1) scaled-h)]
;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame!
(is (mth/close? 400.0 rendered-w))
(is (mth/close? 200.0 rendered-h))))))
(deftest test-proportion-lock-invariance
(testing "Shape proportion-lock attribute remains unchanged"
(let [shape {:id (uuid/custom 10)
:type :rect
:width 200
:height 100
:proportion-lock true
:fills [{:fill-image {:id (uuid/custom 1)
:width 800
:height 600
:keep-aspect-ratio true}}]}
;; Simulate bounds resize interaction
has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape)))
mod-pressed? true
bounds-resize? (and has-img? mod-pressed?)
lock-during-drag (if bounds-resize? false (:proportion-lock shape))]
;; During drag, lock is bypassed (unless Shift is pressed)
(is (false? lock-during-drag))
;; Shape's persistent setting is completely preserved
(is (true? (:proportion-lock shape))))))
+4 -2
View File
@@ -23,13 +23,13 @@
[common-tests.files-migrations-test]
[common-tests.files.shapes-builder-test]
[common-tests.files.validate-test]
[common-tests.files.variant-test]
[common-tests.geom-align-test]
[common-tests.geom-bounds-layout-nil-test]
[common-tests.geom-bounds-map-test]
[common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test]
[common-tests.geom-grid-test]
[common-tests.geom-image-bounds-resize-test]
[common-tests.geom-line-test]
[common-tests.geom-modif-tree-test]
[common-tests.geom-modifiers-test]
@@ -88,6 +88,7 @@
[common-tests.types.token-test]
[common-tests.types.tokens-lib-test]
[common-tests.types.tokens-status-test]
[common-tests.types.variant-test]
[common-tests.undo-stack-test]
[common-tests.uuid-test]))
@@ -103,13 +104,13 @@
'common-tests.files-migrations-0026-test
'common-tests.files-migrations-test
'common-tests.files.validate-test
'common-tests.files.variant-test
'common-tests.geom-align-test
'common-tests.geom-bounds-layout-nil-test
'common-tests.geom-bounds-map-test
'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test
'common-tests.geom-grid-test
'common-tests.geom-image-bounds-resize-test
'common-tests.geom-line-test
'common-tests.geom-modif-tree-test
'common-tests.geom-modifiers-test
@@ -144,6 +145,7 @@
'common-tests.logic.token-test
'common-tests.logic.variants-switch-test
'common-tests.math-test
'common-tests.types.variant-test
'common-tests.media-test
'common-tests.path-names-test
'common-tests.record-test
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.types.tokens-status-test
(:require
+244 -11
View File
@@ -7,10 +7,238 @@
(ns common-tests.types.variant-test
(:require
[app.common.types.variant :as ctv]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest variant-component
(t/is (not (ctv/variant-component? nil)))
(t/is (not (ctv/variant-component? {})))
(t/is (ctv/variant-component? {:variant-id (uuid/next)})))
(t/deftest variant-distance01
(t/deftest variant-shape
(t/is (not (ctv/variant-shape? nil)))
(t/is (not (ctv/variant-shape? {})))
(t/is (ctv/variant-shape? {:variant-id (uuid/next)})))
(t/deftest variant-container
(t/is (not (ctv/variant-container? nil)))
(t/is (not (ctv/variant-container? {})))
(t/is (ctv/variant-container? {:is-variant-container true})))
(t/deftest properties-to-name-test
(t/is (= "" (ctv/properties-to-name [])))
(t/is (= "" (ctv/properties-to-name nil)))
(t/is (= "Button, Primary" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}])))
(t/is (= "Button" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
{:name "Property 2" :value ""}]))))
(t/deftest next-property-number-test
(t/is (= 1 (ctv/next-property-number [])))
(t/is (= 1 (ctv/next-property-number nil)))
(t/is (= 2 (ctv/next-property-number [{:name "Property 1" :value "x"}])))
(t/is (= 4 (ctv/next-property-number [{:name "Property 3" :value "x"}])))
(t/is (= 3 (ctv/next-property-number [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]))))
(t/deftest add-new-property-test
(t/is (= [{:name "Property 1" :value "x"}]
(ctv/add-new-property [] "x")))
(t/is (= [{:name "Property 1" :value "x"}]
(ctv/add-new-property nil "x")))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/add-new-property [{:name "Property 1" :value "x"}] "y"))))
(t/deftest add-new-properties-test
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
(ctv/add-new-properties [] ["a" "b"])))
(t/is (= '({:name "Property 2" :value "b"} {:name "Property 1" :value "a"})
(ctv/add-new-properties nil ["a" "b"])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "a"} {:name "Property 3" :value "b"}]
(ctv/add-new-properties [{:name "Property 1" :value "x"}] ["a" "b"]))))
(t/deftest path-to-properties-test
(t/is (= [] (ctv/path-to-properties "" [])))
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
(ctv/path-to-properties "a / b" nil)))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}
{:name "Property 3" :value "Hover"}]
(ctv/path-to-properties "Button / Primary / Hover" [])))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}
{:name "Property 3" :value "Hover"}
{:name "Property 4" :value ""}]
(ctv/path-to-properties "Button / Primary / Hover" [] 4)))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}]
(ctv/path-to-properties "Button / Primary" [{:name "Property 1" :value "old"}
{:name "Property 2" :value "old2"}]))))
(t/deftest properties-map->formula-test
(t/is (= "" (ctv/properties-map->formula [])))
(t/is (= "" (ctv/properties-map->formula nil)))
(t/is (= "Property 1=Button, Property 2=Primary"
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}])))
(t/is (= "Property 1=Button"
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
{:name "Property 2" :value ""}]))))
(t/deftest properties-formula->map-test
(t/is (= [] (ctv/properties-formula->map "")))
(t/is (= [] (ctv/properties-formula->map nil)))
(t/is (= [{:name "Property 1" :value "Button"} {:name "Property 2" :value "Primary"}]
(ctv/properties-formula->map "Property 1=Button, Property 2=Primary")))
(t/is (= [{:name "Property 1" :value "Button"}]
(ctv/properties-formula->map "Property 1=Button, Property 2="))))
(t/deftest valid-properties-formula?-test
(t/is (= true (ctv/valid-properties-formula? "Property 1=Button, Property 2=Primary")))
(t/is (= false (ctv/valid-properties-formula? "")))
(t/is (= true (ctv/valid-properties-formula? nil)))
(t/is (= false (ctv/valid-properties-formula? "Property 1=Button, Property 2"))))
(t/deftest find-properties-to-remove-test
(t/is (= [] (ctv/find-properties-to-remove [] [])))
(t/is (= [] (ctv/find-properties-to-remove nil nil)))
(t/is (= [{:name "Property 3" :value "z"}]
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 3" :value "z"}]))))
(t/deftest find-properties-to-update-test
(t/is (= [] (ctv/find-properties-to-update [] [])))
(t/is (= [] (ctv/find-properties-to-update nil nil)))
(t/is (= [{:name "Property 1" :value "new-x"}]
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "new-x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "new-x"} {:name "Property 2" :value "new-y"}]
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "new-x"}
{:name "Property 2" :value "new-y"}]))))
(t/deftest find-properties-to-add-test
(t/is (= [] (ctv/find-properties-to-add [] [])))
(t/is (= [] (ctv/find-properties-to-add nil nil)))
(t/is (= [{:name "Property 3" :value "z"}]
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}])))
(t/is (= [{:name "Property 2" :value "y"}]
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]))))
(t/deftest update-number-in-repeated-item-test
(t/is (= "Property" (ctv/update-number-in-repeated-item [] "Property")))
(t/is (= "Property" (ctv/update-number-in-repeated-item nil "Property")))
(t/is (= "Property (1)" (ctv/update-number-in-repeated-item ["Property"] "Property")))
(t/is (= "Property (2)" (ctv/update-number-in-repeated-item ["Property" "Property (1)"] "Property")))
(t/is (= "Property" (ctv/update-number-in-repeated-item ["Other"] "Property"))))
(t/deftest update-number-in-repeated-prop-names-test
(t/is (= [] (ctv/update-number-in-repeated-prop-names [])))
(t/is (= [] (ctv/update-number-in-repeated-prop-names nil)))
(t/is (= [{:name "Property" :value "x"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}])))
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
{:name "Property" :value "y"}])))
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"} {:name "Property (2)" :value "z"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
{:name "Property" :value "y"}
{:name "Property" :value "z"}]))))
(t/deftest find-index-for-property-name-test
(t/is (= nil (ctv/find-index-for-property-name [] "Property 1")))
(t/is (= nil (ctv/find-index-for-property-name nil "Property 1")))
(t/is (= 0 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 1")))
(t/is (= 1 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}] "Property 2")))
(t/is (= nil (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 3"))))
(t/deftest remove-prefix-test
(t/is (= "name" (ctv/remove-prefix "name" "")))
(t/is (= "name" (ctv/remove-prefix "name" nil)))
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button")))
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button / ")))
(t/is (= "Button / Primary" (ctv/remove-prefix "Button / Primary" "Other"))))
(t/deftest merge-properties-test
(t/is (= [] (ctv/merge-properties [] [])))
(t/is (= [] (ctv/merge-properties nil nil)))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"} {:name "Property 3" :value "z"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}])))
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "y"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 2" :value "y"}]))))
(t/deftest compare-properties-test
(t/is (= [] (ctv/compare-properties [])))
(t/is (= [] (ctv/compare-properties nil)))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value nil}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "z"}]])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "*"}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "z"}]]
"*"))))
(t/deftest variant-name-to-name-test
(t/is (= "Button / Primary / Hover" (ctv/variant-name-to-name {:name "Button" :variant-name "Primary, Hover"})))
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name ""})))
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name nil})))
(t/is (= "" (ctv/variant-name-to-name {:name "" :variant-name ""})))
(t/is (= nil (ctv/variant-name-to-name {:name nil :variant-name nil}))))
(t/deftest find-boolean-pair-test
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["on" "off"])))
(t/is (= {"yes" true "no" false} (ctv/find-boolean-pair ["yes" "no"])))
(t/is (= {"true" true "false" false} (ctv/find-boolean-pair ["true" "false"])))
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["off" "on"])))
(t/is (= {"ON" true "OFF" false} (ctv/find-boolean-pair ["ON" "OFF"])))
(t/is (= nil (ctv/find-boolean-pair ["foo" "bar"])))
(t/is (= nil (ctv/find-boolean-pair nil)))
(t/is (= nil (ctv/find-boolean-pair ["on"]))))
(t/deftest same-variant?-test
(t/is (= false (ctv/same-variant? [])))
(t/is (= false (ctv/same-variant? nil)))
(t/is (= true (ctv/same-variant? [{:variant-id "abc"}])))
(t/is (= true (ctv/same-variant? [{:variant-id "abc"} {:variant-id "abc"}])))
(t/is (= false (ctv/same-variant? [{:variant-id "abc"} {:variant-id "def"}])))
(t/is (= false (ctv/same-variant? [{:variant-id ""} {:variant-id ""}]))))
(t/deftest properties-distance01
;;c1: primary, default, rounded, blue, dark
;;c2: primary, hover, squared, blue, dark
;;c3: primary, default, squared, blue, light
@@ -35,12 +263,11 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "light"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)]
(t/is (< dist3 dist2))))
(t/deftest variant-distance02
(t/deftest properties-distance02
;;c1: primary, default, rounded, blue, dark
;;c2: primary, hover, squared, red, dark
;;c3: secondary, hover, rounded, blue, dark
@@ -65,11 +292,11 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "dark"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)]
(t/is (< dist2 dist3))))
(t/deftest variant-distance03
(t/deftest properties-distance03
;;c1: primary, default, rounded, blue, dark
;;c2: secondary, default, rounded, blue, light
;;c3: secondary, hover, squared, blue, dark
@@ -101,12 +328,18 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "dark"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)
dist4 (ctv/distance target props4)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)
dist4 (ctv/properties-distance target props4)]
(t/is (< dist2 dist4))
(t/is (< dist4 dist3))))
(t/deftest properties-distance04
(t/is (= 0 (ctv/properties-distance [] [])))
(t/is (= 0 (ctv/properties-distance nil nil)))
(t/is (= 0 (ctv/properties-distance [{:name "a" :value "x"}] [{:name "a" :value "x"}])))
(t/is (= 2.0 (ctv/properties-distance [{:name "a" :value "x"} {:name "b" :value "y"}] [{:name "a" :value "x"} {:name "b" :value "z"}]))))
@@ -1,58 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns common-tests.types-shape-tree-test
(:require
[app.common.geom.point :as gpt]
[app.common.types.shape-tree :as ctt]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(defn- make-frame
[id parent-id shapes x y width height show-content]
{:id id
:type :frame
:parent-id parent-id
:frame-id parent-id
:shapes (vec shapes)
:x x
:y y
:width width
:height height
:rotation nil
:hidden false
:blocked false
:show-content show-content})
(t/deftest top-nested-frame-clip-content-test
(t/testing "board A (clip) contains a wider board B; point inside both resolves to B"
(let [a-id (uuid/next)
b-id (uuid/next)
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
position (gpt/point 150 150)
result (ctt/top-nested-frame objects position)]
(t/is (= b-id result))))
(t/testing "point inside B but outside A's clipped bounds is not reachable at all"
(let [a-id (uuid/next)
b-id (uuid/next)
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 false)
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
position (gpt/point 300 300)
result (ctt/top-nested-frame objects position)]
;; Outside A (the clip ancestor) and B's visible/clipped region there is
;; not visible either, so no frame should be resolved at that point.
(t/is (= uuid/zero result))))
(t/testing "with show-content true on A, the same point can resolve into B"
(let [a-id (uuid/next)
b-id (uuid/next)
objects {a-id (make-frame a-id uuid/zero [b-id] 0 0 200 200 true)
b-id (make-frame b-id a-id [] 50 50 300 300 false)}
position (gpt/point 300 300)
result (ctt/top-nested-frame objects position)]
(t/is (= b-id result)))))
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth
"Resolves the caller's session cookie to a real profile id.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.handlers.export
"Handle export jobs"
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.handlers.jobs
"REST surface for export jobs, under `/api/export/jobs`.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs
"Export job model and lifecycle.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.scheduler
"Admission control for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.store
"Redis persistence for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.utils
"Temp file ownership for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.router
"Method + path dispatch.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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 SUBSIDIARY SL
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.pool
"Pool of headless render workers.
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more