Compare commits

..

No commits in common. "develop" and "2.16.0-RC10" have entirely different histories.

479 changed files with 59537 additions and 67442 deletions

View File

@ -1,133 +0,0 @@
# `.devenv/` — Per-Workspace AI-Client MCP Configs
This directory carries the pieces needed to point an AI coding agent
(currently Claude Code, opencode, VS Code Copilot, and the OpenAI Codex CLI)
at the MCP servers running inside the parallel devenv instance the developer
is currently working in. Every parallel workspace (`ws0`, `ws1`, …) has its
own copy because the Penpot MCP and Serena MCP host ports are
workspace-specific.
## Layout
```
.devenv/
README.md
scripts/
merge-mcp-config.py # generator helper invoked by manage.sh
shared/ # committed; workspace-independent entries
claude-code.json # Playwright — same for every workspace
opencode.json
vscode.json
codex.toml
templates/ # committed; entries with ${...} port placeholders
claude-code.json # Penpot MCP, Serena MCP — port is the only diff
opencode.json
vscode.json
codex.toml
mcp/ # gitignored; written by manage.sh per workspace
claude-code.json # loaded via Claude Code's --mcp-config flag
opencode.json # loaded via OPENCODE_CONFIG env var
```
One more file is generated outside `.devenv/`, in the directory VS Code itself
auto-discovers (gitignored):
```
.vscode/mcp.json # auto-loaded by GitHub Copilot in VS Code
```
Codex is the exception: it has no way to load an MCP config from an arbitrary
path, and its only project-level config file (`.codex/config.toml`) is one a
developer may already own. So we do **not** write a file for Codex at all —
`start-coding-agent codex` injects our servers as `-c` command-line overrides
built fresh from `shared/codex.toml` + `templates/codex.toml` at launch.
* **`shared/`** holds MCP entries that don't depend on the workspace — the
browser-driving Playwright server today, plus any other workspace-independent
servers we add later. Same content in every workspace, so it's a static
checked-in file.
* **`templates/`** holds the workspace-specific entries (Penpot MCP, Serena
MCP) with `${PENPOT_MCP_PORT}` and `${SERENA_MCP_PORT}` placeholders. The
placeholders are resolved per-workspace from the port-base constants in
`manage.sh`.
* **`mcp/`** (Claude Code, opencode) is the result of merging `shared/` with
the port-substituted `templates/`. `manage.sh` writes these on every
`run-devenv-agentic` pass. Gitignored, dedicated paths with no developer
content — never edit by hand, your edits will be overwritten on the next
reconcile.
* **`.vscode/mcp.json`** is the same merge, but written to the path VS Code
auto-discovers. Because on `ws0` that path *is* the live repo's own file, the
reconcile **deep-merges** into it: any servers you added yourself are kept,
and only the entries we manage (`penpot`, `serena-devenv`, `playwright`) are
(re)written to the current ports. On `ws1+` the file doesn't exist yet, so it
is created from scratch.
* **`scripts/merge-mcp-config.py`** is the generator. Its `json` mode does the
JSON deep-merge (with `--merge-into-existing` for the VS Code path); its
`codex-args` mode prints the `-c` assignments for Codex. `manage.sh`'s
`_merge-mcp-config-json` helper is a thin shim over the former, and
`start-coding-agent` calls the latter directly. Run
`python3 .devenv/scripts/merge-mcp-config.py --help` for the CLI.
## Launching a coding agent
The easiest path is the wrapper command, which knows the right flags per
client, `cd`'s into the target workspace, and refuses to launch unless the
target instance is running and its MCP config has been generated:
```bash
# Default target is ws0 (the live repo).
./manage.sh start-coding-agent claude [...args to forward]
./manage.sh start-coding-agent opencode [...args to forward]
./manage.sh start-coding-agent vscode [...args to forward to 'code']
./manage.sh start-coding-agent codex [...args to forward]
# Target a parallel workspace with --ws N. N is an integer (non-negative);
# 'main', 'ws1' and similar spellings are rejected.
./manage.sh start-coding-agent claude --ws 1
./manage.sh start-coding-agent opencode --ws 2
```
Equivalents by hand (run from inside the workspace directory):
```bash
claude --mcp-config .devenv/mcp/claude-code.json
OPENCODE_CONFIG=.devenv/mcp/opencode.json opencode
code "$PWD" # VS Code auto-discovers .vscode/mcp.json
# Codex: pass our servers as -c overrides (no config file is written).
codex $(python3 .devenv/scripts/merge-mcp-config.py --format codex-args \
.devenv/shared/codex.toml .devenv/templates/codex.toml \
| sed 's/^/-c /')
```
`start-coding-agent codex` does the `-c` wiring for you (and resolves the
workspace's ports first). Because our servers arrive as command-line
overrides, no "trusted project" prompt is involved for them — that prompt only
gates Codex's own `.codex/config.toml`, which we never write.
## Overriding our entries
Both the auto-discovered configs and the launcher-loaded configs sit *on top
of* the developer's global config (with varying precedence rules). All four
clients offer escape hatches for shadowing entries we ship:
* **Claude Code**`claude mcp add --scope local …` installs a private entry
that overrides the one in `mcp/claude-code.json`. Local scope wins.
* **opencode** — drop an `opencode.json` at the repo root with the override
entries you need. opencode's precedence chain is *global → `OPENCODE_CONFIG`
→ project*, so the project file always wins. The root `opencode.json` is
gitignored on purpose, since these overrides are personal.
* **VS Code Copilot** — the reconcile deep-merges into `.vscode/mcp.json`, so
any servers you add there yourself are preserved (only `penpot`,
`serena-devenv` and `playwright` are rewritten). To shadow one of *ours*,
put an entry under the same name in your VS Code user-profile MCP config —
it is loaded alongside the workspace file and wins.
* **Codex CLI** — our servers arrive as `-c` overrides, which are Codex's
highest-precedence layer, so they win over a same-named `[mcp_servers.<name>]`
in your `~/.codex/config.toml` or a project `.codex/config.toml`. To override
one of ours, append your own `-c` after the client name — extra args are
forwarded after ours and the later `-c` wins, e.g.
`./manage.sh start-coding-agent codex -- -c 'mcp_servers.penpot.url="…"'`.
See `docs/technical-guide/developer/agentic-devenv.md` for the broader
client-configuration story (browser remote debugging, AI-client config
schemas, manual setup for unsupported clients).

View File

@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""Combine a shared MCP-server config with a port-substituted template for one
AI coding-agent client.
Invoked per workspace by manage.sh's `write-instance-mcp-configs` (JSON
clients) and by `start-coding-agent` (Codex). Each supported client ships a
`.devenv/shared/<tool>.{json,toml}` (workspace-independent entries, e.g.
Playwright) and a `.devenv/templates/<tool>.{json,toml}` (per-workspace entries
with `${PENPOT_MCP_PORT}` / `${SERENA_MCP_PORT}` placeholders). This script
combines the two for the target client.
Two output modes are supported:
json Deep-merge two JSON documents under a configurable top-level key
(`mcpServers` for Claude Code, `mcp` for opencode, `servers` for
VS Code Copilot) and write the result to <out>. Same-name
entries in the template override entries in shared. With
--merge-into-existing, any pre-existing <out> file is loaded as
the lowest-precedence layer first, so entries the developer
already had are preserved (ours win on name collision). This is
used for VS Code's auto-discovered `.vscode/mcp.json`, which on
ws0 IS the live repo's file and may hold the developer's own
servers; the Claude/opencode outputs live in a dedicated,
gitignored `.devenv/mcp/` path and are written without the flag
(a clean overwrite).
codex-args Deep-merge the two TOML chunks and print one
`dotted.key=<toml-value>` assignment per line to stdout (no
<out> file). The caller wraps each line in a `codex -c` flag.
Codex has no way to load an MCP config from an arbitrary file
path (CODEX_HOME would relocate auth/history too), so rather than
writing the auto-discovered `.codex/config.toml` we inject our
servers as ephemeral per-invocation overrides. This never
touches the developer's project- or user-level Codex config.
In both modes, `${VAR}` placeholders inside *either* chunk are resolved from
the current environment (only template chunks carry placeholders in practice,
but the substitution is uniform either way) using Python's
`os.path.expandvars`. Undefined placeholders are left as `${VAR}` literal text
-- callers (i.e. manage.sh) are responsible for exporting the variables before
invoking the script.
Usage:
merge-mcp-config.py --format json --key <key> [--merge-into-existing] \
<shared> <template> <out>
merge-mcp-config.py --format codex-args <shared> <template>
Exit codes:
0 success
2 argparse error (missing required option, bad value, unreadable input)
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tomllib
from pathlib import Path
def merge_json(
shared_path: Path,
tpl_path: Path,
out_path: Path,
key: str,
merge_into_existing: bool,
) -> None:
"""Deep-merge JSON documents under a single top-level dict key into out.
Precedence (lowest to highest): an existing <out> file (only when
merge_into_existing is set), then shared, then the template. Entries under
`key` are merged by name, so the template wins on a name collision while
every other entry the lower layers contributed is kept. Top-level keys
other than `key` come from the existing file and shared (shared wins).
"""
shared = json.loads(shared_path.read_text())
tpl = json.loads(os.path.expandvars(tpl_path.read_text()))
base: dict = {}
if merge_into_existing and out_path.exists():
base = json.loads(out_path.read_text())
merged: dict = {**base, **shared}
merged[key] = {**base.get(key, {}), **shared.get(key, {}), **tpl.get(key, {})}
out_path.write_text(json.dumps(merged, indent=2) + "\n")
def _deep_merge(base: dict, overlay: dict) -> dict:
"""Recursively merge overlay into base; overlay wins on scalar/list keys."""
out = dict(base)
for k, v in overlay.items():
if isinstance(out.get(k), dict) and isinstance(v, dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def _toml_value(value: object) -> str:
"""Serialize a scalar/list as a TOML literal for a `codex -c` value.
bool is checked before int because `isinstance(True, int)` is True. Strings
are emitted as JSON strings, which are valid TOML basic strings for the
ASCII values our configs carry (commands, args, URLs). Tables never reach
here -- they are flattened into dotted keys by _flatten.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, str):
return json.dumps(value)
if isinstance(value, list):
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
raise TypeError(f"unsupported TOML value type: {type(value).__name__}")
_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
def _key_segment(seg: str) -> str:
"""A dotted-key segment: bare if TOML-safe, else a quoted key."""
return seg if _BARE_KEY.match(seg) else json.dumps(seg)
def _flatten(obj: dict, prefix: list[str]):
"""Yield (dotted-path-segments, leaf-value) for every non-table leaf.
Lists are leaves (TOML arrays), so we do not recurse into them; nested
tables (e.g. an `env` table) are flattened into further dotted keys.
"""
for k, v in obj.items():
path = prefix + [k]
if isinstance(v, dict):
yield from _flatten(v, path)
else:
yield path, v
def emit_codex_args(shared_path: Path, tpl_path: Path) -> None:
"""Print `dotted.key=<toml-value>` lines from the merged TOML chunks."""
shared = tomllib.loads(os.path.expandvars(shared_path.read_text()))
tpl = tomllib.loads(os.path.expandvars(tpl_path.read_text()))
merged = _deep_merge(shared, tpl)
for path, value in _flatten(merged, []):
dotted = ".".join(_key_segment(s) for s in path)
sys.stdout.write(f"{dotted}={_toml_value(value)}\n")
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description=__doc__.split("\n\n", 1)[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--format",
choices=("json", "codex-args"),
required=True,
help="Output mode: 'json' writes a merged file; 'codex-args' prints -c assignments.",
)
parser.add_argument(
"--key",
help="Top-level JSON key under which MCP entries live (required for --format json).",
)
parser.add_argument(
"--merge-into-existing",
action="store_true",
help="json only: layer the merge on top of an existing <out> file, "
"preserving entries already there (ours still win on name collision).",
)
parser.add_argument("shared", type=Path, help="Path to the shared chunk.")
parser.add_argument("template", type=Path, help="Path to the port-placeholder template chunk.")
parser.add_argument(
"out",
type=Path,
nargs="?",
help="Path the merged result is written to (json only; codex-args writes stdout).",
)
args = parser.parse_args(argv)
if args.format == "json":
if not args.key:
parser.error("--key is required when --format json")
if args.out is None:
parser.error("out is required when --format json")
merge_json(args.shared, args.template, args.out, args.key, args.merge_into_existing)
else: # codex-args
if args.key:
parser.error("--key is not accepted when --format codex-args")
if args.merge_into_existing:
parser.error("--merge-into-existing is not accepted when --format codex-args")
if args.out is not None:
parser.error("out path is not accepted when --format codex-args (result goes to stdout)")
emit_codex_args(args.shared, args.template)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -1,8 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
}
}
}

View File

@ -1,8 +0,0 @@
# Workspace-independent MCP servers for the OpenAI Codex CLI.
# This block is concatenated with the port-substituted templates/codex.toml
# by manage.sh's write-instance-mcp-configs to produce .codex/config.toml at
# the workspace root.
[mcp_servers.playwright]
command = "npx"
args = ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]

View File

@ -1,9 +0,0 @@
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"],
"enabled": true
}
}
}

View File

@ -1,9 +0,0 @@
{
"servers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
}
}
}

View File

@ -1,12 +0,0 @@
{
"mcpServers": {
"penpot": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:${PENPOT_MCP_PORT}/mcp", "--allow-http"]
},
"serena-devenv": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:${SERENA_MCP_PORT}/mcp", "--allow-http"]
}
}
}

View File

@ -1,10 +0,0 @@
# Workspace-specific MCP servers for the OpenAI Codex CLI. The PENPOT_MCP_PORT
# and SERENA_MCP_PORT placeholders below are filled in per workspace by
# manage.sh's write-instance-mcp-configs, then the result is concatenated
# with shared/codex.toml to produce .codex/config.toml.
[mcp_servers.penpot]
url = "http://localhost:${PENPOT_MCP_PORT}/mcp"
[mcp_servers.serena-devenv]
url = "http://localhost:${SERENA_MCP_PORT}/mcp"

View File

@ -1,14 +0,0 @@
{
"mcp": {
"penpot": {
"type": "remote",
"url": "http://localhost:${PENPOT_MCP_PORT}/mcp",
"enabled": true
},
"serena-devenv": {
"type": "remote",
"url": "http://localhost:${SERENA_MCP_PORT}/mcp",
"enabled": true
}
}
}

View File

@ -1,12 +0,0 @@
{
"servers": {
"penpot": {
"type": "http",
"url": "http://localhost:${PENPOT_MCP_PORT}/mcp"
},
"serena-devenv": {
"type": "http",
"url": "http://localhost:${SERENA_MCP_PORT}/mcp"
}
}
}

View File

@ -1,8 +1,9 @@
description: Create a report to help us improve
name: Bug report
title: ""
title: "bug: "
type: Bug
labels: ["needs triage"]
labels: ["triage"]
projects: ["penpot/8"]
body:
- type: markdown

View File

@ -1,8 +1,9 @@
description: Suggest an idea for this project.
labels: ["needs triage"]
name: "Feature request"
title: ""
title: "feature: "
type: Enhancement
projects: ["penpot/8"]
body:
- type: markdown

View File

@ -6,6 +6,7 @@ on:
jobs:
build-and-push:
name: Build and push DevEnv Docker image
environment: release-admins
runs-on: penpot-runner-02
steps:
@ -38,13 +39,3 @@ jobs:
tags: ${{ env.DOCKER_IMAGE }}:latest
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🚀 *[PENPOT] New devenv available*
📄 You may want to update your devenv.
@alvaro

View File

@ -0,0 +1,22 @@
name: _MAIN-STAGING
on:
workflow_dispatch:
schedule:
- cron: '26 5-20 * * 1-5'
jobs:
build-bundle:
uses: ./.github/workflows/build-bundle.yml
secrets: inherit
with:
gh_ref: "main-staging"
build_wasm: "yes"
build_storybook: "yes"
build-docker:
needs: build-bundle
uses: ./.github/workflows/build-docker.yml
secrets: inherit
with:
gh_ref: "main-staging"

View File

@ -19,6 +19,7 @@ permissions:
jobs:
release:
environment: release-admins
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.vars.outputs.gh_ref }}

View File

@ -1,84 +0,0 @@
name: "CI: Backend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'backend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'backend/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation

View File

@ -1,57 +0,0 @@
name: "CI: Common"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Tests
working-directory: ./common
run: |
./scripts/test

View File

@ -1,71 +0,0 @@
name: "CI: Frontend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frotend/**/*'
- 'common/**/*'
- 'render-wasm/**/*'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frotend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components

View File

@ -1,93 +0,0 @@
name: "CI: Integration"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frotend/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frotend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
overwrite: true
retention-days: 3

View File

@ -1,58 +0,0 @@
name: "CI: Library"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
- 'library/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
- 'library/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./library
run: |
./scripts/test

View File

@ -1,83 +0,0 @@
name: "CI: Plugins"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'plugins/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example

View File

@ -1,57 +0,0 @@
name: "CI: WASM"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test

411
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,411 @@
name: "CI"
defaults:
run:
shell: bash
on:
pull_request:
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
concurrency:
group: ${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
if: ${{ !github.event.pull_request.draft }}
name: "Linter"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint Common
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Lint Frontend
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Lint Backend
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Exporter
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Library
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./common
run: |
./scripts/test
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./library
run: |
./scripts/test
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 1/3"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="1/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 2/3"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="2/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 3/3"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="3/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3

10
.gitignore vendored
View File

@ -15,12 +15,6 @@
.repl
/*.jpg
/*.md
!CHANGES.md
!CONTRIBUTING.md
!README.md
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
/*.png
/*.svg
/*.sql
@ -93,10 +87,6 @@
/.pnpm-store
/.vscode
/.idea
*.iml
/.claude
/.playwright-mcp
/.devenv/mcp/
/opencode.json
/.codex/
/tools/__pycache__

View File

@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.

View File

@ -1,160 +0,0 @@
# Creating Issues
Create GitHub issues only on explicit request. Use `gh` CLI authenticated to `penpot/penpot`.
## Title Derivation
Derive the title from the source material (bug report, user feedback, feature request, etc.) — not from any pre-existing title which may be auto-generated or stale.
### Bug titles (descriptive present tense)
Describe the symptom as it appears to the user. Format: `[Where] [present-tense verb] when [condition]`.
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
Do **not** start bug titles with "Fix" or any imperative verb — state what's broken, not command a fix.
### Feature / Enhancement titles (imperative mood)
Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
### Universal rules
- **Include the "where"** — specify the UI location or module (e.g. "in the sidebar", "on the stroke options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, `[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality
- **Two problems → cover both** — if the description has two distinct but related issues, capture both joined by "and"
## Metadata
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
## Issue Body Template
Write the body to a temp file to avoid shell quoting issues:
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
## Creating the Issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Derived title>" \
--label "<label>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
## Setting the Issue Type
`gh issue create` can't set Issue Type directly. Use GraphQL after creation.
**Issue Type IDs for penpot/penpot:**
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task
**Set it:**
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
## Verification
```bash
gh issue view <NUMBER> --repo penpot/penpot \
--json title,labels,milestone,projectItems \
--jq '{title, milestone: .milestone.title, labels: [.labels[].name], projects: [.projectItems[].title]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
## Cleanup
```bash
rm -f /tmp/issue-body.md
```
## See Also
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`

View File

@ -1,123 +1,10 @@
# CHANGELOG
## 2.17.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
### :sparkles: New features & Enhancements
- Show a read-only W × H size badge below the bounding box of the current selection (by @bittoby) [#9205](https://github.com/penpot/penpot/issues/9205)
- Expose `variants` retrieval on `LibraryComponent` via `isVariant()` type guard in plugin API (by @opcode81) [#9185](https://github.com/penpot/penpot/issues/9185) (PR: [#9302](https://github.com/penpot/penpot/pull/9302))
- Add search bar to prototype interaction destination dropdown (by @EvaMarco) [#8618](https://github.com/penpot/penpot/issues/8618) (PR: [#9769](https://github.com/penpot/penpot/pull/9769))
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
- Add dashed stroke customization with dash and gap inputs (by @EvaMarco) [#3881](https://github.com/penpot/penpot/issues/3881) (PR: [#9765](https://github.com/penpot/penpot/pull/9765))
- Add author, relative timestamp and short identifier to history entries (by @FairyPigDev) [#7660](https://github.com/penpot/penpot/issues/7660) (PR: [#9132](https://github.com/penpot/penpot/pull/9132))
- Add typography token row to multiselected texts [#9336](https://github.com/penpot/penpot/issues/9336) (PR: [#9128](https://github.com/penpot/penpot/pull/9128))
- Optimize propagation of tokens [#9261](https://github.com/penpot/penpot/issues/9261) (PR: [#9144](https://github.com/penpot/penpot/pull/9144))
- Add typography information to token dropdown option [#9377](https://github.com/penpot/penpot/issues/9377) (PR: [#9375](https://github.com/penpot/penpot/pull/9375))
- Cache OIDC provider records to skip per-login discovery (by @Dexterity104) [#9294](https://github.com/penpot/penpot/issues/9294) (PR: [#9295](https://github.com/penpot/penpot/pull/9295))
- Validate shape on add-object to catch malformed inputs early (by @Dexterity104) [#9507](https://github.com/penpot/penpot/issues/9507) (PR: [#9291](https://github.com/penpot/penpot/pull/9291))
- Remove unreachable try/catch in hex->hsl (by @Dexterity104) [#9244](https://github.com/penpot/penpot/issues/9244) (PR: [#9245](https://github.com/penpot/penpot/pull/9245))
- Remove stray debug log in exporter upload-resource (by @iot2edge) [#9270](https://github.com/penpot/penpot/issues/9270) (PR: [#9272](https://github.com/penpot/penpot/pull/9272))
- Release pool connection during font variant creation (by @Dexterity104) [#9286](https://github.com/penpot/penpot/issues/9286) (PR: [#9287](https://github.com/penpot/penpot/pull/9287))
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109))
- Add list view mode to color picker UI [#4420](https://github.com/penpot/penpot/issues/4420) (PR: [#9953](https://github.com/penpot/penpot/pull/9953))
- Use Clipboard API consistently across the application (by @MilosM348) [#6514](https://github.com/penpot/penpot/issues/6514) (PR: [#9188](https://github.com/penpot/penpot/pull/9188))
- Use `$` as DTCG token/group discriminator and make `$description` optional [#8342](https://github.com/penpot/penpot/issues/8342) (PR: [#9912](https://github.com/penpot/penpot/pull/9912))
- Match version preview banner text to History sidebar labels (by @MilosM348) [#9503](https://github.com/penpot/penpot/issues/9503) (PR: [#9697](https://github.com/penpot/penpot/pull/9697))
- Use "copia" as duplicate suffix for Spanish (by @Rene0422) [#9623](https://github.com/penpot/penpot/issues/9623) (PR: [#9671](https://github.com/penpot/penpot/pull/9671))
- Harden CORS middleware to not reflect Origin with credentials enabled [#9659](https://github.com/penpot/penpot/issues/9659) (PR: [#9675](https://github.com/penpot/penpot/pull/9675))
- Revert token migrations on clashing names to prevent data loss [#9816](https://github.com/penpot/penpot/issues/9816) (PR: [#9950](https://github.com/penpot/penpot/pull/9950))
- Update contributing guidelines with current issue tags and CSS linting rules [#9900](https://github.com/penpot/penpot/issues/9900) (PR: [#9418](https://github.com/penpot/penpot/pull/9418))
- Add composite typography token input to the Design sidebar [#9932](https://github.com/penpot/penpot/issues/9932) (PR: [#9128](https://github.com/penpot/penpot/pull/9128), [#9375](https://github.com/penpot/penpot/pull/9375), [#8749](https://github.com/penpot/penpot/pull/8749))
- Avoid deduplicating temporary export files to prevent stale content (by @yong2bba) [#9970](https://github.com/penpot/penpot/issues/9970) (PR: [#9959](https://github.com/penpot/penpot/pull/9959))
### :bug: Bugs fixed
- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure [Github #9092](https://github.com/penpot/penpot/issues/9092)
- Fix LDAP provider params schema typo (`bind-passwor``bind-password`) introduced during the `clojure.spec``malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide``:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored (`userinfo` / `token`) in the OIDC callback, causing "incomplete user info" failures during registration [Github #9108](https://github.com/penpot/penpot/issues/9108)
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled [Github #8877](https://github.com/penpot/penpot/issues/8877)
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947)
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [Github #8577](https://github.com/penpot/penpot/issues/8577)
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
- Fix hyphens stripped from export filenames (by @jamesrayammons) [Github #8901](https://github.com/penpot/penpot/issues/8901)
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
- Fix non-functional clear icon in change email modal inputs (by @Dexterity104) [Github #8977](https://github.com/penpot/penpot/issues/8977)
- Disable save button after saving account profile settings (by @Dexterity104) [Github #8979](https://github.com/penpot/penpot/issues/8979)
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
- Allow deleting the profile avatar after uploading [Github #9067](https://github.com/penpot/penpot/issues/9067)
- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516)
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [Github #9137](https://github.com/penpot/penpot/issues/9137)
- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` [Github #8409](https://github.com/penpot/penpot/issues/8409)
- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
- Fix prototype interaction targets appearing in View Mode automatically when library component changes (by @jeffrey701) [#9049](https://github.com/penpot/penpot/issues/9049) (PR: [#9695](https://github.com/penpot/penpot/pull/9695))
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
- Prevent viewers from overwriting file thumbnails (by @jony376) [#9284](https://github.com/penpot/penpot/issues/9284) (PR: [#9285](https://github.com/penpot/penpot/pull/9285))
- Fix plugin API showing incorrect error messages for invalid operations (by @bitcompass) [#9417](https://github.com/penpot/penpot/issues/9417) (PR: [#9486](https://github.com/penpot/penpot/pull/9486))
- Add inactivity timeout to SSE sessions to match Streamable HTTP sessions [#9432](https://github.com/penpot/penpot/issues/9432) (PR: [#9464](https://github.com/penpot/penpot/pull/9464))
- Fix component variant switching behaving differently on two identical copies (by @MischaPanch) [#9498](https://github.com/penpot/penpot/issues/9498) (PR: [#9434](https://github.com/penpot/penpot/pull/9434))
- Populate is-indirect flag on file libraries from relation graph (by @Dexterity104) [#9506](https://github.com/penpot/penpot/issues/9506) (PR: [#9289](https://github.com/penpot/penpot/pull/9289))
- Add missing error message for invalid shadow token [#9583](https://github.com/penpot/penpot/issues/9583) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
- Fix moving a component in a library triggering stale update notification in dependent files [#9629](https://github.com/penpot/penpot/issues/9629) (PR: [#9616](https://github.com/penpot/penpot/pull/9616))
- Fix newly created token not visible when placed above existing tokens in the tree [#9711](https://github.com/penpot/penpot/issues/9711) (PR: [#9803](https://github.com/penpot/penpot/pull/9803))
- Fix B(V) input label misalignment in HSB color picker [#9731](https://github.com/penpot/penpot/issues/9731) (PR: [#9793](https://github.com/penpot/penpot/pull/9793))
- Fix text style name input appending font name instead of replacing it when edited [#9785](https://github.com/penpot/penpot/issues/9785) (PR: [#9784](https://github.com/penpot/penpot/pull/9784))
- Fix shadow token creation not allowing empty blur or spread value [#9808](https://github.com/penpot/penpot/issues/9808) (PR: [#9809](https://github.com/penpot/penpot/pull/9809))
- Fix thinner line in path when its stroke is deleted and added again [#9823](https://github.com/penpot/penpot/issues/9823) (PR: [#9836](https://github.com/penpot/penpot/pull/9836))
- Fix layers panel perceivable lag when displaying changes [#9834](https://github.com/penpot/penpot/issues/9834)
- Fix settings form visual layout broken after recent contribution [#9882](https://github.com/penpot/penpot/issues/9882) (PR: [#9883](https://github.com/penpot/penpot/pull/9883))
- Fix crash when duplicating shapes with fill/stroke properties [#9893](https://github.com/penpot/penpot/issues/9893) (PR: [#9647](https://github.com/penpot/penpot/pull/9647))
- Fix S3 storage failing with IRSA/Web Identity Token credentials (by @jpc2350) [#9927](https://github.com/penpot/penpot/issues/9927) (PR: [#9928](https://github.com/penpot/penpot/pull/9928))
- Fix onboarding template spinner stuck after failed template download (by @jeffrey701) [#9931](https://github.com/penpot/penpot/issues/9931) (PR: [#9504](https://github.com/penpot/penpot/pull/9504))
- Fix stroke caps not working correctly when there are other nodes in the middle of a path [#9987](https://github.com/penpot/penpot/issues/9987) (PR: [#9989](https://github.com/penpot/penpot/pull/9989))
- Fix missing three dots button for column and row edit menu in WebKit/Safari [#9993](https://github.com/penpot/penpot/issues/9993) (PR: [#9994](https://github.com/penpot/penpot/pull/9994))
- Fix exported path with strokes being cut off in SVG file [#9995](https://github.com/penpot/penpot/issues/9995) (PR: [#9996](https://github.com/penpot/penpot/pull/9996))
- Fix French Canada locale falling back to French translations instead of French Canadian (by @alexismo) [#10017](https://github.com/penpot/penpot/issues/10017) (PR: [#10027](https://github.com/penpot/penpot/pull/10027))
## 2.16.0 (Unreleased)
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
@ -175,8 +62,6 @@
- Add v2.16 release notes (What's new modal) [#9945](https://github.com/penpot/penpot/issues/9945) (PR: [#9940](https://github.com/penpot/penpot/pull/9940))
### :bug: Bugs fixed
- Fix plugin API `Board.addRulerGuide` attaching guides to the page instead of the board due to a shadowed `id` binding; also correct the `'content:write'` permission error message and the `RulerGuideProxy` name (by @girafic) [#8225](https://github.com/penpot/penpot/issues/8225) (PR: [#8632](https://github.com/penpot/penpot/pull/8632))
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
@ -290,6 +175,7 @@
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
## 2.15.1
### :sparkles: New features & Enhancements
@ -300,6 +186,7 @@
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138))
## 2.15.0
### :sparkles: New features & Enhancements

View File

@ -159,7 +159,7 @@ To save time on both sides, please avoid submitting PRs that:
### Good first issues
We use the `good first issue` label to mark issues appropriate for newcomers.
We use the `easy fix` label to mark issues appropriate for newcomers.
## Commit Guidelines
@ -175,26 +175,26 @@ Commit messages must follow this format:
### Commit types
| Emoji | Description |
| ---------------------- | -------------------------- |
| :bug: | Bug fix |
| :sparkles: | Improvement or enhancement |
| :tada: | New feature |
| :recycle: | Refactor |
| :lipstick: | Cosmetic changes |
| :ambulance: | Critical bug fix |
| :books: | Documentation |
| :construction: | Work in progress |
| :boom: | Breaking change |
| :wrench: | Configuration update |
| :zap: | Performance improvement |
| :whale: | Docker-related change |
| :paperclip: | Other non-relevant changes |
| :arrow_up: | Dependency update |
| :arrow_down: | Dependency downgrade |
| :fire: | Removal of code or files |
| Emoji | Description |
|-------|-------------|
| :bug: | Bug fix |
| :sparkles: | Improvement or enhancement |
| :tada: | New feature |
| :recycle: | Refactor |
| :lipstick: | Cosmetic changes |
| :ambulance: | Critical bug fix |
| :books: | Documentation |
| :construction: | Work in progress |
| :boom: | Breaking change |
| :wrench: | Configuration update |
| :zap: | Performance improvement |
| :whale: | Docker-related change |
| :paperclip: | Other non-relevant changes |
| :arrow_up: | Dependency update |
| :arrow_down: | Dependency downgrade |
| :fire: | Removal of code or files |
| :globe_with_meridians: | Add or update translations |
| :rocket: | Epic or highlight |
| :rocket: | Epic or highlight |
### Rules
@ -231,19 +231,6 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
./scripts/lint
```
For frontend SCSS, we use `stylelint` for linting and
`Prettier` for formatting:
```bash
cd frontend
# Lint SCSS
pnpm run lint:scss (does not modify files)
# Fix SCSS formatting (modifies files in place)
pnpm run fmt:scss
```
Ideally, run these as git pre-commit hooks.
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
setting this up.
@ -272,23 +259,23 @@ By submitting code you agree to and can certify the following:
> By making a contribution to this project, I certify that:
>
> (a) The contribution was created in whole or in part by me and I have the
> right to submit it under the open source license indicated in the file; or
> right to submit it under the open source license indicated in the file; or
>
> (b) The contribution is based upon previous work that, to the best of my
> knowledge, is covered under an appropriate open source license and I have
> the right under that license to submit that work with modifications,
> whether created in whole or in part by me, under the same open source
> license (unless I am permitted to submit under a different license), as
> indicated in the file; or
> knowledge, is covered under an appropriate open source license and I have
> the right under that license to submit that work with modifications,
> whether created in whole or in part by me, under the same open source
> license (unless I am permitted to submit under a different license), as
> indicated in the file; or
>
> (c) The contribution was provided directly to me by some other person who
> certified (a), (b) or (c) and I have not modified it.
> certified (a), (b) or (c) and I have not modified it.
>
> (d) I understand and agree that this project and the contribution are public
> and that a record of the contribution (including all personal information
> I submit with it, including my sign-off) is maintained indefinitely and
> may be redistributed consistent with this project or the open source
> license(s) involved.
> and that a record of the contribution (including all personal information
> I submit with it, including my sign-off) is maintained indefinitely and
> may be redistributed consistent with this project or the open source
> license(s) involved.
### Signed-off-by

View File

@ -5,7 +5,7 @@
<p align="center">
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==">
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-blue.svg">
</a>
<a href="https://community.penpot.app" rel="nofollow">
<img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app">

View File

@ -5,8 +5,8 @@
We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue.
Please report vulnerabilities through the [GitHub Security Advisories](https://github.com/penpot/penpot/security/advisories
) feature in the Penpot repository.
Please report vulnerabilities via email to: **[support@penpot.app]**
### What to include:

View File

@ -7,7 +7,6 @@ list.
## Security
* Alisher (@7megaumka7)
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
* Vaibhav Shukla

View File

@ -67,8 +67,7 @@
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}
software.amazon.awssdk/sts {:mvn/version "2.44.4"}}
software.amazon.awssdk/s3 {:mvn/version "2.44.4"}}
:paths ["src" "resources" "target/classes"]
:aliases

View File

@ -198,14 +198,14 @@
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.avatar-bg-url}}{% endif %}"
background="{{organization-logo}}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
{% if organization.initials %}{{organization.initials}}{% endif %}
{% if organization-initials %}{{organization-initials}}{% endif %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ organization.name|abbreviate:50 }}
“{{ organization-name|abbreviate:25 }}”
</span>
</div>
</td>

View File

@ -1 +1 @@
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”

View File

@ -1,6 +1,6 @@
Hello!
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”.
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”.
Accept invitation using this link:

View File

@ -1,270 +0,0 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-px-425 {
width: 425px !important;
max-width: 425px;
}
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="background-color:#E5E5E5;">
<div style="background-color:#E5E5E5;">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %},
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Your Enterprise subscription is coming up for renewal. Here's a summary of what's included.
</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin-top:20px">
<b>Renewal date:</b> {{ renewal-date }}
</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin-top:10px">
<b>Estimated amount:</b> {{ estimated-amount }}
</div>
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin:10px 0">
<b>Organizations covered:</b> {% if organizations|empty? %}No organizations yet.{% endif %}
</div>
{% for org in organizations %}
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;margin-bottom:5px">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
background="{% if org.logo %}{{org.logo}}{% else %}{{org.avatar-bg-url}}{% endif %}"
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
{% if org.initials %}{{org.initials}}{% endif %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
{{ org.name|abbreviate:50 }}
</span>
</div>
{% endfor %}
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
This amount is based on current member counts across your organizations. You can adjust members from the <a href="{{ public-uri }}/admin-console/" target="_blank">Admin Console</a>.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Check our <a href="https://penpot.app/terms" target="_blank">Terms and Conditions</a> and <a href="https://penpot.app/privacy" target="_blank">Privacy Policy.</a></div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
Enjoy!</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
The Penpot team.</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{% include "app/email/includes/footer.html" %}
</div>
</body>
</html>

View File

@ -1 +0,0 @@
Your Enterprise subscription renews on {{ renewal-date }}

View File

@ -1,17 +0,0 @@
Hi {% if user-name %}{{ user-name }}{% endif %},
Your Enterprise subscription is coming up for renewal. Here's a summary of what's included.
Renewal date: {{ renewal-date }}
Estimated amount: {{ estimated-amount }}
Organizations covered: {% if organizations|empty? %}No organizations yet.{% endif %}
{% for org in organizations %}
- {{ org.name }}
{% endfor %}
This amount is based on current member counts across your organizations. You can adjust members from the Admin Console.
Check our Terms and Conditions and Privacy Policy.
Enjoy!
The Penpot team.

View File

@ -27,7 +27,6 @@
[app.rpc.commands.profile :as profile]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.cache :as cache]
[app.util.inet :as inet]
[app.util.json :as json]
[buddy.sign.jwk :as jwk]
@ -695,24 +694,15 @@
(db/pgarray? roles)
(assoc :roles (db/decode-pgarray roles #{}))))
;; A short TTL avoids paying the OIDC discovery + JWKS fetch on every
;; login; Caffeine will not store the entry when the load fn throws,
;; so a transient failure at the provider's discovery endpoint does
;; not poison the cache.
(defonce ^:private provider-cache
(cache/create :expire "10m" :max-size 64))
(defn- load-provider
[cfg id]
(when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true})
(decode-row))]
(case (:type params)
"oidc" (prepare-oidc-provider cfg params))))
;; TODO: add cache layer for avoid build an discover each time
(defn get-provider
[cfg id]
(try
(cache/get provider-cache id (partial load-provider cfg))
(when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true})
(decode-row))]
(case (:type params)
"oidc" (prepare-oidc-provider cfg params)))
(catch Throwable cause
(l/err :hint "unable to configure custom SSO provider"
:provider (str id)

View File

@ -315,8 +315,8 @@
(defn get-file
"Get file, resolve all features and apply migrations.
Useful when you have plan to apply massive or not surgical
operations on file, because it removes the overhead of lazy fetching
Usefull when you have plan to apply massive or not cirurgical
operations on file, because it removes the ovehead of lazy fetching
and decoding."
[cfg file-id & {:as opts}]
(db/run! cfg get-file* file-id opts))
@ -843,12 +843,7 @@
l.vern,
l.is_shared,
l.version,
fls.synced_at,
NOT EXISTS (
SELECT 1 FROM file_library_rel AS direct
WHERE direct.file_id = ?::uuid
AND direct.library_file_id = l.id
) AS is_indirect
fls.synced_at
FROM libs AS l
JOIN project AS p
ON p.id = l.project_id
@ -860,8 +855,12 @@
(defn get-file-libraries
[conn file-id]
(into []
(map decode-row-features)
(db/exec! conn [sql:get-file-libraries file-id file-id file-id])))
(comp
;; FIXME: :is-indirect set to false to all rows looks
;; completly useless
(map #(assoc % :is-indirect false))
(map decode-row-features))
(db/exec! conn [sql:get-file-libraries file-id file-id])))
(defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of

View File

@ -109,11 +109,6 @@
[:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int]
;; Explicit CORS allowlist used when the :cors flag is enabled.
;; Configured via PENPOT_ALLOWED_ORIGINS as a comma/whitespace
;; separated list of origins (e.g. "https://plugins.example.com").
[:allowed-origins {:optional true} [::sm/set :string]]
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string]
@ -300,7 +295,7 @@
(sm/explainer schema:config))
(defn read-config
"Reads the configuration from environment variables and decodes all
"Reads the configuration from enviroment variables and decodes all
known values."
[& {:keys [prefix default] :or {prefix "penpot"}}]
(->> (read-env prefix)

View File

@ -431,19 +431,14 @@
:id ::invite-to-team
:schema schema:invite-to-team))
(def ^:private schema:organization-data
[:map
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])
(def ^:private schema:invite-to-org
[:map
[:invited-by ::sm/text]
[:organization-name ::sm/text]
[:organization-initials [:maybe :string]]
[:organization-logo ::sm/uri]
[:user-name [:maybe ::sm/text]]
[:token ::sm/text]
[:organization schema:organization-data]])
[:token ::sm/text]])
(def invite-to-org
"Org member invitation email."
@ -451,21 +446,6 @@
:id ::invite-to-org
:schema schema:invite-to-org))
(def ^:private schema:renewal-notice
[:map
[:user-name [:maybe ::sm/text]]
[:renewal-date ::sm/text]
[:estimated-amount ::sm/text]
[:organizations [:vector schema:organization-data]]])
(def renewal-notice
"Enterprise subscription renewal notice email."
(template-factory
:id ::renewal-notice
:schema schema:renewal-notice))
(def ^:private schema:join-team
[:map
[:invited-by ::sm/text]

View File

@ -22,8 +22,7 @@
(and (= "unlimited" type) (not (contains? canceled-status status)))
(ct/duration {:days 30})
(and (contains? #{"enterprise" "nitrate"} type)
(not (contains? canceled-status status)))
(and (= "enterprise" type) (not (contains? canceled-status status)))
(ct/duration {:days 90})
:else

View File

@ -144,15 +144,6 @@
{::yres/status 404
::yres/body (ex-data err)})
(defmethod handle-error :nitrate-unavailable
[err request _]
(binding [l/*context* (request->context request)]
(l/warn :hint "nitrate is unreachable; blocking request" :cause err)
;; Do not leak Nitrate's internal URL/status to the client; the
;; full context is already logged above for operators.
{::yres/status 503
::yres/body {:type :nitrate-unavailable}}))
(defmethod handle-error :internal
[error request parent-cause]
(binding [l/*context* (request->context request)]

View File

@ -208,40 +208,28 @@
:compile (constantly wrap-errors)})
(defn- with-cors-headers
"Build CORS response headers. Only emits permissive headers when the
request `origin` is present on the configured `allowed` allowlist;
otherwise returns the headers unchanged except for `Vary: Origin` so
shared caches don't leak per-origin responses."
[headers origin allowed]
(cond-> (assoc headers "vary" "Origin")
(and (some? origin) (contains? allowed origin))
(-> (assoc "access-control-allow-origin" origin)
(assoc "access-control-allow-credentials" "true")
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
(assoc "access-control-expose-headers" "content-type")
(assoc "access-control-allow-headers" "x-frontend-version, x-client, content-type, accept"))))
[headers origin]
(-> headers
(assoc "access-control-allow-origin" origin)
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
(assoc "access-control-allow-credentials" "true")
(assoc "access-control-expose-headers" "content-type, set-cookie")
(assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie")))
(defn wrap-cors
[handler allowed]
[handler]
(fn [request]
(let [response (if (= (yreq/method request) :options)
{::yres/status 204}
(handler request))
origin (yreq/get-header request "origin")]
(update response ::yres/headers with-cors-headers origin allowed))))
(update response ::yres/headers with-cors-headers origin))))
(def cors
{:name ::cors
:compile (fn [& _]
(when (contains? cf/flags :cors)
(let [allowed (not-empty (cf/get :allowed-origins))]
(if allowed
(fn [handler] (wrap-cors handler allowed))
(do
(l/wrn :hint (str "cors flag is enabled but :allowed-origins is empty; "
"CORS middleware disabled (fail-closed). "
"Configure PENPOT_ALLOWED_ORIGINS with a comma-separated list of trusted origins."))
nil)))))})
wrap-cors))})
(def restrict-methods
{:name ::restrict-methods

View File

@ -21,7 +21,7 @@
(defn- write!
[^OutputStream output ^bytes data]
(l/trc :hint "writing data" :data data :length (alength data))
(l/trc :hint "writting data" :data data :length (alength data))
(.write output data)
(.flush output))

View File

@ -7,7 +7,6 @@
(ns app.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l]
@ -29,16 +28,14 @@
(defn- request-builder
[cfg method uri shared-key profile-id request-params]
(fn []
(http/req cfg
(cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
{:skip-ssrf-check? true})))
(http/req cfg (cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(defn- with-retries
[handler max-retries]
@ -62,29 +59,14 @@
(fn []
(let [response (handler)
status (:status response)]
(when-not status
(l/error :hint "could't do the nitrate request, it is probably down"
:uri uri)
;; TODO decide what to do when Nitrate is inaccesible
nil)
(cond
(nil? status)
(do
(l/error :hint "couldn't do the nitrate request, it is probably down"
:uri uri)
(ex/raise :type :nitrate-unavailable
:hint (str "nitrate is unreachable at " uri)))
(>= status 500)
;; Nitrate is up enough to answer (or the proxy is) but the
;; service itself is failing; treat as unavailable so callers
;; surface the static error page.
(do
(l/error :hint "nitrate request failed with server error status"
:uri uri
:status status
:body (:body response))
(ex/raise :type :nitrate-unavailable
:status status
:hint (str "nitrate is unavailable, HTTP " status " at " uri)))
(>= status 400)
;; For client error status codes (4xx), fail immediately without validation
;; For error status codes (4xx, 5xx), fail immediately without validation
(do
(when (not= status 404) ;; Don't need to log 404
(l/error :hint "nitrate request failed with error status"
@ -189,7 +171,6 @@
"day"
"week"
"year"]]
[:manual :boolean]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
@ -275,42 +256,6 @@
[:vector schema:org-summary]
params)))
(def ^:private schema:org-summary-counts
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:team-count ::sm/int]
[:member-count ::sm/int]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
(defn- get-owned-orgs-summary-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
orgs (request-to-nitrate cfg :get
(str baseuri
"/api/users/"
profile-id
"/owned-organizations-summary")
[:vector schema:org-summary-counts]
params)]
(mapv (fn [org]
(if-let [logo-id (:logo-id org)]
(assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id))
org))
orgs)))
(defn- delete-owned-orgs-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :post
(str baseuri
"/api/users/"
profile-id
"/delete-owned-organizations")
nil params)))
(defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
@ -322,7 +267,7 @@
organization-id
"/add-team")
cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])]
custom-photo (when-let [logo-id (get-in team [:organization :logo-id])]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team
custom-photo
@ -391,24 +336,6 @@
profile-id)
schema:subscription params)))
(def ^:private schema:subscription-warning
[:maybe
[:map {:title "SubscriptionWarning"}
[:type {:optional true} ::sm/text]
[:days-from-expiry {:optional true} ::sm/int]
[:days-until-expiry {:optional true} ::sm/int]
[:expiration-date {:optional true} schema:timestamp]]])
(defn- get-subscription-warning-api
[cfg {:keys [penpot-id profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
penpot-id (or penpot-id profile-id)]
(request-to-nitrate cfg :get
(str baseuri
"/api/subscription-warning/"
penpot-id)
schema:subscription-warning params)))
(defn- get-connectivity-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
@ -421,31 +348,6 @@
[:map
[:cancel-at [:maybe schema:timestamp]]])
(defn- get-org-permissions-api
[cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/permissions")
[:map
[:organization-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:permissions [:map-of :keyword :string]]]
params)))
(defn- get-org-members-api
[cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/members-list")
[:vector ::sm/uuid]
params)))
(defn- redeem-activation-code-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
@ -467,17 +369,12 @@
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-api cfg)
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
:get-org-members (partial get-org-members-api cfg)
:delete-owned-orgs (partial delete-owned-orgs-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
:get-org-permissions (partial get-org-permissions-api cfg)
:delete-team (partial delete-team-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg)
:get-subscription-warning (partial get-subscription-warning-api cfg)
:connectivity (partial get-connectivity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
@ -489,27 +386,21 @@
(defn add-nitrate-licence-to-profile
"Enriches a profile map with subscription information from Nitrate.
Adds a :subscription field containing the user's license details.
Returns the original profile unchanged if the request fails for a reason
other than Nitrate being unreachable. When Nitrate is unreachable the
`:nitrate-unavailable` exception propagates so the request is rejected."
Returns the original profile unchanged if the request fails."
[cfg profile]
(try
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
(assoc profile :subscription subscription))
(catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause)
(do
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))))
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))
(defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or org data is nil.
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
Returns the original team unchanged if the request fails or org data is nil."
[cfg team params]
(try
(let [params (assoc (or params {}) :team-id (:id team))
@ -522,13 +413,10 @@
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team))
(catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause)
(do
(l/error :hint "failed to get team organization info"
:team-id (:id team)
:cause cause)
team)))))
(l/error :hint "failed to get team organization info"
:team-id (:id team)
:cause cause)
team)))
(defn set-team-organization
"Associates a team with an organization in Nitrate.
@ -546,3 +434,7 @@
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))

View File

@ -112,30 +112,22 @@
::quotes/profile-id profile-id
::quotes/project-id project-id})
;; Acquire a row-level lock on the team and re-read its features
;; inside the same transaction before the read-modify-write below.
;; Without the lock, two concurrent create-file calls on the same
;; team can both observe the same team.features value, each
;; compute a different union, and the second UPDATE silently
;; overwrites the first (lost update under READ COMMITTED).
(let [team-features (-> (db/exec-one! conn
["SELECT features FROM team WHERE id = ? FOR UPDATE"
team-id])
:features
(db/decode-pgarray #{}))]
(when-let [new-features (-> features
(set/difference team-features)
(set/difference cfeat/no-team-inheritable-features)
(not-empty))]
(let [features (-> new-features
(set/union team-features)
(set/difference cfeat/no-team-inheritable-features)
(into-array))]
;; FIXME: IMPORTANT: this code can have race conditions, because
;; we have no locks for updating team so, creating two files
;; concurrently can lead to lost team features updating
(when-let [features (-> features
(set/difference (:features team))
(set/difference cfeat/no-team-inheritable-features)
(not-empty))]
(let [features (-> features
(set/union (:features team))
(set/difference cfeat/no-team-inheritable-features)
(into-array))]
(db/update! conn :team
{:features features}
{:id team-id}
{::db/return-keys false}))))
(db/update! conn :team
{:features features}
{:id (:id team)}
{::db/return-keys false})))
(-> (create-file cfg params)
(vary-meta assoc ::audit/props {:team-id team-id}))))

View File

@ -409,7 +409,10 @@
[cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-edition-permissions! conn profile-id file-id)
;; TODO For now we check read permissions instead of write,
;; to allow viewer users to update thumbnails. We might
;; review this approach on the future.
(files/check-read-permissions! conn profile-id file-id)
(when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media))

View File

@ -109,6 +109,9 @@
(fn [{:keys [data uploads]}]
(or (seq data) (seq uploads)))]])
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
;; connection around the font creation
(defn- prepare-font-data-from-uploads
"Assembles each chunked-upload session in `uploads` (a `{mtype
session-id}` map) into a temp file, validates the media type and
@ -168,18 +171,20 @@
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
(teams/check-edition-permissions! pool profile-id team-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (if (some? uploads)
(db/tx-run! cfg prepare-font-data-from-uploads params)
(prepare-font-data-from-legacy params))]
(create-font-variant cfg (assoc params :profile-id profile-id))))
[cfg {:keys [::rpc/profile-id team-id uploads] :as params}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(teams/check-edition-permissions! conn profile-id team-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
(let [params (if (some? uploads)
(prepare-font-data-from-uploads cfg params)
(prepare-font-data-from-legacy params))]
(create-font-variant cfg (assoc params :profile-id profile-id))))))
(defn create-font-variant
[{:keys [::sto/storage] :as cfg} {:keys [data] :as params}]
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
(letfn [(generate-missing [data]
(let [data (media/run {:cmd :generate-fonts :input data})]
(when (and (not (contains? data "font/otf"))
@ -204,15 +209,22 @@
:bucket "team-font-variant"})))
(persist-fonts-files! [data]
(into {} (keep (fn [[kind mtype]]
(when-let [params (prepare-font data mtype)]
[kind (sto/put-object! storage params)])))
[[:otf "font/otf"]
[:ttf "font/ttf"]
[:woff1 "font/woff"]
[:woff2 "font/woff2"]]))
(let [otf-params (prepare-font data "font/otf")
ttf-params (prepare-font data "font/ttf")
wf1-params (prepare-font data "font/woff")
wf2-params (prepare-font data "font/woff2")]
(insert-font-variant! [conn {:keys [woff1 woff2 otf ttf]}]
(cond-> {}
(some? otf-params)
(assoc :otf (sto/put-object! storage otf-params))
(some? ttf-params)
(assoc :ttf (sto/put-object! storage ttf-params))
(some? wf1-params)
(assoc :woff1 (sto/put-object! storage wf1-params))
(some? wf2-params)
(assoc :woff2 (sto/put-object! storage wf2-params)))))
(insert-font-variant! [{:keys [woff1 woff2 otf ttf]}]
(db/insert! conn :team-font-variant
{:id (uuid/next)
:team-id (:team-id params)
@ -226,14 +238,14 @@
:otf-file-id (:id otf)
:ttf-file-id (:id ttf)}))]
(let [tpoint (ct/tpoint)
mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content]
(+ acc (if (bytes? content)
(alength ^bytes content)
(fs/size content))))
0
data)]
(let [tpoint (ct/tpoint)
mtypes (vec (keys data))
total-size (reduce-kv (fn [acc _ content]
(+ acc (if (bytes? content)
(alength ^bytes content)
(fs/size content))))
0
data)]
(l/dbg :hint "create-font-variant"
:step "init"
@ -245,7 +257,7 @@
(let [data (generate-missing data)
assets (persist-fonts-files! data)
result (db/tx-run! cfg #(insert-font-variant! (::db/conn %) assets))
result (insert-font-variant! assets)
elapsed (tpoint)]
(l/dbg :hint "create-font-variant"

View File

@ -12,8 +12,6 @@
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.config :as cf]
[app.db :as db]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@ -59,22 +57,6 @@
[cfg _params]
(nitrate/call cfg :connectivity {}))
(def ^:private schema:subscription-warning
[:maybe
[:map {:title "SubscriptionWarning"}
[:type {:optional true} ::sm/text]
[:days-from-expiry {:optional true} ::sm/int]
[:days-until-expiry {:optional true} ::sm/int]
[:expiration-date {:optional true} ct/schema:inst]]])
(sv/defmethod ::get-subscription-warning
{::rpc/auth true
::doc/added "2.14"
::sm/params [:map]
::sm/result schema:subscription-warning}
[cfg {:keys [::rpc/profile-id]}]
(nitrate/call cfg :get-subscription-warning {:profile-id profile-id}))
(def ^:private schema:redeem-activation-code-params
[:map {:title "RedeemActivationCodeParams"}
[:activation-code ::sm/text]])
@ -128,47 +110,12 @@
AND t.id = ANY(?)
AND t.deleted_at IS NULL")
(def ^:private sql:get-teams-files-counts
"SELECT p.team_id, count(*) AS total
(def sql:get-team-files-count
"SELECT count(*) AS total
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ANY(?)
AND f.deleted_at IS NULL
GROUP BY p.team_id")
(defn- get-team-files-counts
[conn team-ids]
(if (seq team-ids)
(let [ids-array (db/create-array conn "uuid" team-ids)]
(->> (db/exec! conn [sql:get-teams-files-counts ids-array])
(reduce (fn [acc {:keys [team-id total]}]
(assoc acc team-id (long total)))
{})))
{}))
(defn- build-leave-org-plan
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
files-counts (get-team-files-counts conn all-teams)
has-files? (fn [id] (pos? (long (get files-counts id 0))))
deletable (remove has-files? teams-to-delete)
keep-default? (or keep-default-team-requested?
(and default-team-id (has-files? default-team-id)))
to-detach (cond-> (into [] (remove (set deletable) teams-to-delete))
(and default-team-id keep-default?) (conj default-team-id))]
{:deletable-team-ids deletable
:keep-default-team? keep-default?
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
:detach-from-org-team-ids to-detach}))
(defn get-leave-org-summary
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
(let [{:keys [deletable-team-ids detach-from-org-team-ids]}
(build-leave-org-plan cfg default-team-id teams-to-delete nil)]
{:teams-to-delete (count deletable-team-ids)
:teams-to-transfer teams-to-transfer-count
:teams-to-exit teams-to-exit-count
:teams-to-detach (count detach-from-org-team-ids)}))
WHERE p.team_id = ?
AND f.deleted_at IS NULL")
(def ^:private schema:leave-org
[:map
@ -183,18 +130,6 @@
[:id ::sm/uuid]
[:reassign-to {:optional true} ::sm/uuid]]]]])
(def ^:private schema:get-leave-org-summary-result
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]])
(def ^:private schema:get-leave-org-summary
[:map
[:id ::sm/uuid]
[:default-team-id ::sm/uuid]])
(defn- get-organization-teams-for-user
[{:keys [::db/conn] :as cfg} org-summary profile-id]
@ -284,14 +219,16 @@
:code :not-valid-teams))))
(defn leave-org
[{:keys [::db/conn] :as cfg}
{:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation keep-default-team-requested?]}]
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
{:keys [deletable-team-ids
keep-default-team?
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)]
[{:keys [::db/conn] :as cfg} {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}]
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id])
:total)
delete-default-team? (= default-team-files-count 0)]
;; assert that the received teams are valid, checking the different constraints
(when-not skip-validation
@ -299,27 +236,20 @@
(assert-membership cfg profile-id id)
;; delete only eligible teams (non-protected and without files)
(doseq [id deletable-team-ids]
(teams/delete-team cfg {:profile-id profile-id
:team-id id}))
;; delete the teams-to-delete
(doseq [id teams-to-delete]
(teams/delete-team cfg {:profile-id profile-id :team-id id}))
;; leave the teams-to-leave
(doseq [{:keys [id reassign-to]} teams-to-leave]
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
(when default-team-id
(if keep-default-team?
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])
(teams/delete-team cfg {:profile-id profile-id
:team-id default-team-id})))
;; Detach retained owned teams from the organization in Nitrate.
;; Nitrate will rehome them to its fallback/default org.
(doseq [team-id detach-from-org-team-ids]
(nitrate/call cfg :remove-team-from-org {:team-id team-id
:organization-id id}))
;; Delete default-team-id if empty; otherwise keep it and prefix the name.
(if delete-default-team?
(do
(db/update! conn :team {:is-default false} {:id default-team-id})
(teams/delete-team cfg {:profile-id profile-id :team-id default-team-id}))
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]))
;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
@ -336,25 +266,6 @@
(leave-org cfg (assoc params :profile-id profile-id)))
(sv/defmethod ::get-leave-org-summary
{::rpc/auth true
::doc/added "2.18"
::sm/params schema:get-leave-org-summary
::sm/result schema:get-leave-org-summary-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id id default-team-id]}]
(let [{:keys [valid-teams-to-delete-ids
valid-teams-to-transfer
valid-teams-to-exit
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
teams-to-transfer-count (count valid-teams-to-transfer)
teams-to-exit-count (count valid-teams-to-exit)]
(when-not valid-default-team
(ex/raise :type :validation
:code :not-valid-teams))
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
(def ^:private schema:remove-team-from-org
[:map
[:team-id ::sm/uuid]
@ -369,20 +280,6 @@
(assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization
(when (contains? cf/flags :nitrate)
(let [org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})]
(if (nil? org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(when-not (nitrate-perms/allowed? :move-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))))
;; Api call to nitrate
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
@ -391,45 +288,6 @@
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
nil)
(def ^:private sql:get-team-invitation-emails
"SELECT email_to
FROM team_invitation
WHERE team_id = ?
AND valid_until > now()")
(def ^:private sql:delete-team-external-invitations
"DELETE FROM team_invitation
WHERE team_id = ?
AND email_to = ANY(?)
AND valid_until > now()")
(def ^:private sql:get-profiles-by-emails
"SELECT id, email
FROM profile
WHERE email = ANY(?)
AND deleted_at IS NULL")
(defn- get-external-invitation-info
"Returns info about external (non-org-member) invitations pending for a team.
External invitations are those sent to users who are not members of the given org.
Returns {:allows-anybody bool :external-emails [...]}"
[{:keys [::db/conn] :as cfg} team-id organization-id]
(let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id})
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})]
(if allows-anybody
{:allows-anybody true :external-emails []}
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id])
emails (map :email-to invitation-emails)]
(if (empty? emails)
{:allows-anybody false :external-emails []}
(let [emails-array (db/create-array conn "text" (vec emails))
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
external-emails (->> profiles
(remove #(contains? org-member-ids (:id %)))
(map :email)
(vec))]
{:allows-anybody false :external-emails external-emails}))))))
(def ^:private schema:add-team-to-organization
[:map
@ -447,173 +305,15 @@
(assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :nitrate)
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
source-org-id (get-in team-with-org [:organization :id])
source-org-perms (when source-org-id
(nitrate/call cfg :get-org-permissions
{:organization-id source-org-id}))
target-org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})
target-org-same-owner? (and (some? source-org-perms)
(some? target-org-perms)
(= (:owner-id source-org-perms)
(:owner-id target-org-perms)))]
(when (nil? target-org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions"))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
;; Add teammates to the org if needed
(doseq [{member-id :profile-id} team-members
:when (not= member-id profile-id)]
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
;; Team already belongs to an organization: check move-teams on source org.
(when (some? source-org-id)
(when (nil? source-org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions"))
(when-not (nitrate-perms/allowed? :move-team
{:org-perms source-org-perms
:profile-id profile-id
:target-org-same-owner? target-org-same-owner?})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))
;; Always check target create-teams permission (new/add and move flows).
(when-not (nitrate-perms/allowed? :create-team
{:org-perms target-org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to add teams in this organization")))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
;; Add teammates to the org if needed
(doseq [{member-id :profile-id} team-members
:when (not= member-id profile-id)]
(teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
;; Delete pending invitations for users who are not members of the target organization
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
(when (and (not allows-anybody) (seq external-emails))
(let [conn (::db/conn cfg)
emails-array (db/create-array conn "text" external-emails)]
(db/exec! conn [sql:delete-team-external-invitations team-id emails-array])))))
;; Api call to nitrate
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
nil)
(def ^:private schema:check-org-members-params
[:map {:title "CheckOrgMembersParams"}
[:organization-id ::sm/uuid]
[:emails [:vector ::sm/email]]])
(sv/defmethod ::check-org-members
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:check-org-members-params
::sm/result [:map-of :string :boolean]
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}]
(or (when (contains? cf/flags :nitrate)
(assert-membership cfg profile-id organization-id)
(let [emails-array (db/create-array conn "text" emails)
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles)
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))]
(into {}
(map (fn [email]
(let [pid (get email->id email)]
[email (boolean (and pid (contains? org-member-ids pid)))])))
emails)))
{}))
(def ^:private schema:all-org-members-in-team-params
[:map {:title "CheckOrgMembersInTeamParams"}
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(sv/defmethod ::all-org-members-in-team
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-org-members-in-team-params
::sm/result ::sm/boolean}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
org-member-ids (into #{} org-members)
team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
(every? #(contains? team-member-ids %) org-member-ids)))
false))
(def ^:private schema:all-team-members-in-orgs-params
[:map {:title "CheckTeamMembersInOrgsParams"}
[:team-id ::sm/uuid]
[:organization-ids [:vector ::sm/uuid]]])
(sv/defmethod ::all-team-members-in-orgs
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:all-team-members-in-orgs-params
::sm/result [:map-of ::sm/uuid ::sm/boolean]}
[cfg {:keys [::rpc/profile-id team-id organization-ids]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})
team-member-ids (into #{} (map :profile-id team-members))]
;; Validate requester membership in all orgs before fetching members.
(run! #(assert-membership cfg profile-id %) organization-ids)
(into {}
(map (fn [organization-id]
(let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id})
org-member-ids (into #{} org-members)]
[organization-id
(every? #(contains? org-member-ids %) team-member-ids)])))
organization-ids)))
{}))
(def ^:private schema:check-team-external-invitations-params
[:map {:title "CheckTeamExternalInvitationsParams"}
[:team-id ::sm/uuid]
[:organization-id ::sm/uuid]])
(def ^:private schema:check-team-external-invitations-result
[:map {:title "CheckTeamExternalInvitationsResult"}
[:has-external-invitations ::sm/boolean]
[:allows-anybody ::sm/boolean]])
(sv/defmethod ::check-team-external-invitations
{::rpc/auth true
::doc/added "2.17"
::sm/params schema:check-team-external-invitations-params
::sm/result schema:check-team-external-invitations-result
::db/transaction true}
[cfg {:keys [::rpc/profile-id team-id organization-id]}]
(if (contains? cf/flags :nitrate)
(let [perms (teams/get-permissions cfg profile-id team-id)]
(when-not (or (:is-admin perms) (:is-owner perms))
(ex/raise :type :validation
:code :insufficient-permissions))
(assert-membership cfg profile-id organization-id)
(let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)]
{:has-external-invitations (boolean (seq external-emails))
:allows-anybody allows-anybody}))
{:has-external-invitations false
:allows-anybody false}))

View File

@ -110,10 +110,8 @@
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
(catch Throwable cause
(if (= :not-found (-> cause ex-data :type))
{:id uuid/zero :fullname "Anonymous User"}
(throw cause)))))
(catch Throwable _
{:id uuid/zero :fullname "Anonymous User"})))
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
@ -485,16 +483,8 @@
{:deleted-at deleted-at}
{:id profile-id})
;; Delete owned organizations on the fly (no grace period).
;; Nitrate iterates the user's owned orgs and, per org, calls
;; Penpot back through two paths: ::notify-user-organizations-deletion
;; (during delete-owned-orgs) and ::notify-organization-deletion.
;; Both preserve org teams unchanged and only prefix or delete
;; imported "Your Penpot" teams according to whether they still have files.
(when (contains? cf/flags :nitrate)
(nitrate/call cfg :delete-owned-orgs {:profile-id profile-id})
;; Remove the user from any remaining org memberships.
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id}))
;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
;; Schedule cascade deletion to a worker
(wrk/submit! {::db/conn conn
@ -503,6 +493,7 @@
:deleted-at deleted-at
:id profile-id}})
(-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg)))))
@ -529,32 +520,6 @@
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
{:editors editors}))
;; --- QUERY: Owned Organizations Summary (for delete-account modal)
(def ^:private schema:owned-organization-summary
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:team-count ::sm/int]
[:member-count ::sm/int]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:custom-photo {:optional true} [:maybe ::sm/text]]])
(def ^:private schema:get-owned-organizations-summary-result
[:vector schema:owned-organization-summary])
(sv/defmethod ::get-owned-organizations-summary
"List organizations owned by the current profile with team and member counts.
Used by the delete-account modal to warn the user about cascading deletion."
{::doc/added "2.18"
::sm/result schema:get-owned-organizations-summary-result}
[cfg {:keys [::rpc/profile-id]}]
(if (contains? cf/flags :nitrate)
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
[]))
;; --- HELPERS
(def sql:owned-teams

View File

@ -12,7 +12,6 @@
[app.common.features :as cfeat]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -194,9 +193,7 @@
(dm/with-open [conn (db/open pool)]
(cond->> (get-teams conn profile-id)
(contains? cf/flags :nitrate)
(map #(nitrate/add-org-info-to-team cfg % params))
(contains? cf/flags :nitrate)
(remove #(get-in % [:organization :expired-license])))))
(map #(nitrate/add-org-info-to-team cfg % params)))))
(def ^:private sql:get-owned-teams
"SELECT t.id, t.name,
@ -509,27 +506,11 @@
(sv/defmethod ::create-team
{::doc/added "1.17"
::sm/params schema:create-team}
[cfg {:keys [::rpc/profile-id organization-id] :as params}]
[cfg {:keys [::rpc/profile-id] :as params}]
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
::quotes/profile-id profile-id})
;; When creating inside an org, verify the user has permission to do so.
;; Fail closed: if org permissions cannot be fetched, deny the operation.
(when (and organization-id (contains? cf/flags :nitrate))
(let [org-perms (nitrate/call cfg :get-org-permissions
{:organization-id organization-id})]
(if (nil? org-perms)
(ex/raise :type :validation
:code :not-allowed
:hint "Unable to verify organization permissions")
(when-not (nitrate-perms/allowed? :create-team
{:org-perms org-perms
:profile-id profile-id})
(ex/raise :type :validation
:code :not-allowed
:hint "You are not allowed to create teams in this organization")))))
(let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features)
(set/difference cfeat/no-team-inheritable-features))
@ -776,31 +757,16 @@
(defn delete-team
"Mark a team for deletion"
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}]
(let [team (get-team conn :profile-id profile-id :team-id team-id)
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team params)
team)
perms (get team :permissions)
org (:organization team)
in-org? (and (contains? cf/flags :nitrate) org)
can-delete?
(if in-org?
(nitrate-perms/allowed? :delete-team
{:org-perms {:owner-id (dm/get-in team [:organization :owner-id])
:permissions (dm/get-in team [:organization :permissions])}
:profile-id profile-id
:team-perms perms})
(boolean (:is-owner perms)))]
perms (get team :permissions)]
(when-not can-delete?
(when-not (:is-owner perms)
(ex/raise :type :validation
:code :only-owner-can-delete-team))
;; Protect the user's personal default team from deletion.
;; Org-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files.
(when (and (:is-default team) (not in-org?))
(when (:is-default team)
(ex/raise :type :validation
:code :non-deletable-team
:hint "impossible to delete default team"))

View File

@ -14,7 +14,6 @@
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -113,19 +112,8 @@
(let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites notifications))))
(defn- assert-email-can-be-invited
"Asserts that member is an org member when the org
restricts who can be added to teams."
[member org-member-ids]
(when (some? org-member-ids)
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))]
(when-not is-member?
(ex/raise :type :validation
:code :email-not-org-member
:hint "The invited email is not a member of the organization")))))
(defn- create-invitation
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
(assert (db/connection-map? cfg)
"expected cfg with valid connection")
@ -142,13 +130,6 @@
:code :email-domain-is-not-allowed
:hint "email domain is in the blacklist"))
;; When nitrate is active and the team belongs to an org, check that
;; the email is already an org member unless the org explicitly allows adding anybody.
(when (and (contains? cf/flags :nitrate)
(:organization team))
(assert-email-can-be-invited member org-member-ids))
;; When we have email verification disabled and invitation user is
;; already present in the database, we proceed to add it to the
;; team as-is, without email roundtrip.
@ -237,26 +218,32 @@
:to email
:invited-by (:fullname profile)
:user-name (:fullname member)
:organization organization
:organization-name (:name organization)
:organization-logo (:logo organization)
:organization-initials (:initials organization)
:token itoken
:extra-data ptoken}))
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (dm/get-in team [:organization :name])
:token itoken
:extra-data ptoken})))
(let [team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)]
(eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team
:public-uri (cf/get :public-uri)
:to email
:invited-by (:fullname profile)
:team (:name team)
:organization (:organization-name team)
:token itoken
:extra-data ptoken}))))
itoken)))))
(defn create-org-invitation
[cfg {:keys [::rpc/profile-id] :as params}]
[cfg {:keys [::rpc/profile-id id name initials logo] :as params}]
(let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg
(assoc params
:organization {:id id :name name :initials initials :logo logo}
:profile profile
:role :editor))))
@ -322,18 +309,7 @@
- emails (set) + role (single role for all emails)
- invitations (vector of {:email :role} maps)"
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
(let [;; Enrich team with org info once for all invitations when nitrate is active
team (if (contains? cf/flags :nitrate)
(nitrate/add-org-info-to-team cfg team {})
team)
org (:organization team)
org-id (:id org)
restricted? (and org-id (not (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org})))
org-member-ids (when restricted?
(into #{} (nitrate/call cfg :get-org-members {:organization-id org-id})))
params (assoc params :team team :org-member-ids org-member-ids)
;; Normalize input to a consistent format: [{:email :role}]
(let [;; Normalize input to a consistent format: [{:email :role}]
invitation-data (cond
;; Case 1: emails + single role (create invitations style)
(and emails role)

View File

@ -19,7 +19,7 @@
of the object. This function can be applied to the object returned by the
`get-object` but also to the RPC return value (in case you don't provide
the return value calculated key under `::key` metadata prop.
- `::reuse-key?` enables reusing the key calculated on first time; useful
- `::reuse-key?` enables reusing the key calculated on first time; usefull
when the target object is not retrieved on the RPC (typical on retrieving
dependent objects).
"

View File

@ -37,7 +37,7 @@
data (-> (sto/content (:path content))
(sto/wrap-with-hash hash))
content {::sto/content data
::sto/deduplicate? false
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:profile-id profile-id
:content-type (:mtype content)

View File

@ -12,13 +12,11 @@
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :refer [schema:team-with-organization schema:organization-with-avatar]]
[app.common.types.organization :refer [schema:team-with-organization]]
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]]
[app.config :as cf]
[app.db :as db]
[app.email :as eml]
[app.loggers.audit :as audit]
[app.media :as media]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@ -31,8 +29,7 @@
[app.rpc.notifications :as notifications]
[app.storage :as sto]
[app.util.services :as sv]
[app.worker :as wrk]
[cuerdas.core :as str]))
[app.worker :as wrk]))
(defn- profile-to-map [profile]
@ -51,8 +48,7 @@
[cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile)
(assoc :theme (:theme profile))
(assoc :lang (:lang profile)))))
(assoc :theme (:theme profile)))))
;; ---- API: get-teams
@ -300,61 +296,46 @@ RETURNING id, deleted_at;")
nil)
(defn manage-deleted-organization-teams
"For a deleted organization, preserve org teams unchanged and only prefix or
delete member Your Penpot teams depending on whether they still contain files."
[cfg {:keys [organization-id organization-name teams]}]
(let [all-team-ids (->> teams
(map :id)
(filter uuid?)
distinct
(into []))
your-penpot-team-ids (->> teams
(filter :is-your-penpot)
(map :id)
(filter uuid?)
distinct
(into []))]
(when (seq all-team-ids)
"For a list of teams, rename those with files and delete those without, then notify users."
[cfg {:keys [teams organization-name]}]
(let [teams (->> teams (filter uuid?) distinct (into []))]
(when (seq teams)
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
(db/tx-run!
cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [teams-with-files (if (seq your-penpot-team-ids)
(->> (db/exec! conn [sql:get-teams-files-counts
(db/create-array conn "uuid" your-penpot-team-ids)])
(filter (fn [{:keys [total]}] (pos? total)))
(map :team-id)
(into #{}))
#{})
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
(let [teams-array (db/create-array conn "uuid" teams)
teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array])
(filter (fn [{:keys [total]}] (pos? total)))
(map :team-id)
(into #{}))
teams-to-keep (->> teams (filter teams-with-files) (into []))
teams-to-delete (->> teams (remove teams-with-files) (into []))]
;; Org teams move to the fallback org unchanged. Only imported
;; Your Penpot teams keep the org prefix when they still have files.
(when (seq teams-to-prefix)
;; Rename teams that have files in one go
(when (seq teams-to-keep)
(db/exec! conn [sql:prefix-teams-name-and-unset-default
org-prefix
(db/create-array conn "uuid" teams-to-prefix)]))
(db/create-array conn "uuid" teams-to-keep)]))
;; Empty imported Your Penpot teams disappear entirely.
;; Soft-delete empty teams in one go
(soft-delete-teams! cfg teams-to-delete)
(notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete)
(notifications/notify-organization-deletion cfg organization-name teams teams-to-delete)
nil)))))))
(sv/defmethod ::notify-organization-deletion
"For a deleted organization, preserve org teams and only prefix or delete
imported Your Penpot teams before notifying connected users."
"For a list of teams, rename them with the name of the deleted org, and notify
of the deletion to the connected users"
{::doc/added "2.15"
::sm/params schema:notify-organization-deletion
::rpc/auth false}
[cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
teams (:teams org-summary)]
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
:organization-id (:id org-summary)
:teams teams})
teams (->> (:teams org-summary)
(map :id))]
(manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)})
nil))
;; ---- API: notify-user-organizations-deletion
@ -364,18 +345,15 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]])
(sv/defmethod ::notify-user-organizations-deletion
"For a given user, find all owned organizations and apply the deleted-org
transfer rules to their imported Your Penpot teams."
"For a given user, find all owned organizations and rename or delete their teams."
{::doc/added "2.18"
::sm/params schema:notify-user-organizations-deletion}
[cfg {:keys [profile-id]}]
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
(doseq [org owned-orgs]
(let [organization-name (:name org)
teams (:teams org)]
(manage-deleted-organization-teams cfg {:organization-name organization-name
:organization-id (:id org)
:teams teams}))))
teams (map :id (:teams org))]
(manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name}))))
nil)
@ -476,7 +454,10 @@ RETURNING id, deleted_at;")
{::doc/added "2.15"
::sm/params [:map
[:email ::sm/email]
[:organization schema:organization-with-avatar]]}
[:id ::sm/uuid]
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo ::sm/uri]]}
[cfg params]
(db/tx-run! cfg ti/create-org-invitation params)
nil)
@ -491,7 +472,6 @@ RETURNING id, deleted_at;")
ti.email_to AS email,
ti.created_at AS sent_at,
p.fullname AS name,
p.id AS profile_id,
p.photo_id
FROM team_invitation AS ti
LEFT JOIN profile AS p
@ -513,7 +493,6 @@ LEFT JOIN profile AS p
[:email ::sm/email]
[:sent-at ::sm/inst]
[:name {:optional true} [:maybe ::sm/text]]
[:profile-id {:optional true} [:maybe ::sm/uuid]]
[:photo-url {:optional true} ::sm/uri]]])
(sv/defmethod ::get-org-invitations
@ -565,33 +544,6 @@ LEFT JOIN profile AS p
nil))
;; API: delete-all-org-invitations
(def ^:private sql:delete-all-org-invitations
"DELETE FROM team_invitation AS ti
WHERE ti.org_id = ?
OR ti.team_id = ANY(?);")
(def ^:private schema:delete-all-org-invitations-params
[:map
[:organization-id ::sm/uuid]])
(sv/defmethod ::delete-all-org-invitations
"Delete every pending invitation associated with an organization (org-level + team-level).
Called from Nitrate when an organization is about to be deleted, so users that click
their invitation token hit the existing invalid-token landing page."
{::doc/added "2.18"
::sm/params schema:delete-all-org-invitations-params
::rpc/auth false}
[cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
team-ids (->> (:teams org-summary)
(map :id))]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids-array (db/create-array conn "uuid" team-ids)]
(db/exec! conn [sql:delete-all-org-invitations organization-id ids-array]))))
nil))
;; API: remove-from-org
@ -651,8 +603,7 @@ LEFT JOIN profile AS p
[:map
[:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int]
[:teams-to-detach ::sm/int]])
[:teams-to-exit ::sm/int]])
(sv/defmethod ::get-remove-from-org-summary
"Get a summary of the teams that would be deleted, transferred, or exited
@ -672,154 +623,7 @@ LEFT JOIN profile AS p
(when-not valid-default-team
(ex/raise :type :validation
:code :not-valid-teams))
(cnit/get-leave-org-summary cfg
default-team-id
valid-teams-to-delete-ids
(count valid-teams-to-transfer)
(count valid-teams-to-exit))))
;; API: send-renewal-email
(def ^:private schema:send-renewal-email-params
[:map
[:profile-id ::sm/uuid]
[:user-email ::sm/email]
[:user-name [:maybe ::sm/text]]
[:renewal-date :string]
[:estimated-amount :double]
[:organizations [:vector schema:organization-with-avatar]]])
(sv/defmethod ::send-renewal-email
"Send an Enterprise subscription renewal notice email to a user."
{::doc/added "2.17"
::sm/params schema:send-renewal-email-params
::rpc/auth false}
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
(let [amount-str (format "$%.2f" estimated-amount)
user-name (if (str/empty? user-name)
(:fullname (profile/get-profile cfg profile-id))
user-name)]
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(eml/send! {::eml/conn conn
::eml/factory eml/renewal-notice
:public-uri (cf/get :public-uri)
:to user-email
:user-name user-name
:renewal-date renewal-date
:estimated-amount amount-str
:organizations organizations}))))
nil)
;; API: exists-org-team-invitations-for-non-members /
;; delete-org-team-invitations-for-non-members
(def ^:private sql:get-profile-emails-by-ids
"SELECT email
FROM profile
WHERE id = ANY(?)
AND deleted_at IS NULL")
(def ^:private sql:exists-non-member-org-team-invitations
"SELECT EXISTS (
SELECT 1
FROM team_invitation
WHERE team_id = ANY(?)
AND email_to <> ALL(?)
) AS non_member")
(def ^:private sql:delete-non-member-org-team-invitations
"DELETE FROM team_invitation
WHERE team_id = ANY(?)
AND email_to <> ALL(?)
RETURNING email_to")
(def ^:private schema:org-team-invitations-for-non-members-params
[:map
[:team-ids [:vector ::sm/uuid]]
[:member-ids [:vector ::sm/uuid]]])
(def ^:private schema:exists-org-team-invitations-for-non-members-result
[:map [:exists ::sm/boolean]])
(defn- org-team-invitations-for-non-members-arrays
"Member emails and PG arrays used by exists/delete org team invitation endpoints."
[conn {:keys [team-ids member-ids]}]
(let [member-ids-array (db/create-array conn "uuid" member-ids)
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
(map :email)
(into #{}))]
{:emails-array (db/create-array conn "text" (vec member-emails))
:teams-array (db/create-array conn "uuid" team-ids)}))
(defn- non-member-org-team-invitations-exist?
[conn params]
(let [{:keys [emails-array teams-array]}
(org-team-invitations-for-non-members-arrays conn params)]
(-> (db/exec-one! conn [sql:exists-non-member-org-team-invitations
teams-array
emails-array])
:non-member)))
(sv/defmethod ::exists-org-team-invitations-for-non-members
"Return if there are any team invitations for emails that are not organization members."
{::doc/added "2.18"
::sm/params schema:org-team-invitations-for-non-members-params
::sm/result schema:exists-org-team-invitations-for-non-members-result}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
{:exists (boolean (non-member-org-team-invitations-exist? conn params))})))
(sv/defmethod ::delete-org-team-invitations-for-non-members
"Delete team invitations for emails that are not organization members."
{::doc/added "2.18"
::sm/params schema:org-team-invitations-for-non-members-params
::db/transaction true}
[cfg params]
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [{:keys [emails-array teams-array]}
(org-team-invitations-for-non-members-arrays conn params)]
(db/exec! conn [sql:delete-non-member-org-team-invitations
teams-array
emails-array])
nil))))
;; ---- API: push-audit-events
(def ^:private schema:nitrate-audit-event
[:map {:title "NitrateAuditEvent"}
[:name [:and [:string {:max 250}]
[:re #"[\d\w-]{1,50}"]]]
[:profile-id ::sm/uuid]
[:props {:optional true} [:map-of :keyword :any]]])
(def ^:private schema:push-audit-events-params
[:map {:title "PushAuditEventsParams"}
[:events [:vector schema:nitrate-audit-event]]])
(defn- submit-nitrate-audit-event
[cfg {:keys [name profile-id props]}]
(let [now (ct/now)]
(audit/submit* cfg {:type "action"
:name name
:profile-id profile-id
:props (or props {})
:context {}
:tracked-at now
:created-at now
:source "nitrate"
:ip-addr "0.0.0.0"})))
(sv/defmethod ::push-audit-events
"Push audit events from Nitrate to Penpot audit log"
{::doc/added "2.19"
::sm/params schema:push-audit-events-params
::rpc/auth false}
[{:keys [::db/pool] :as cfg} {:keys [events]}]
(let [telemetry? (contains? cf/flags :telemetry)
audit-log? (contains? cf/flags :audit-log)
enabled? (and (not (db/read-only? pool))
(or audit-log? telemetry?))]
(when (and enabled? (seq events))
(run! (partial submit-nitrate-audit-event cfg) events))
nil))
{:teams-to-delete (count valid-teams-to-delete-ids)
:teams-to-transfer (count valid-teams-to-transfer)
:teams-to-exit (count valid-teams-to-exit)}))

View File

@ -34,12 +34,11 @@
(defn notify-organization-deletion
[cfg organization-id organization-name teams deleted-teams]
[cfg organization-name teams deleted-teams]
(let [msgbus (::mbus/msgbus cfg)]
(mbus/pub! msgbus
:topic uuid/zero
:message {:type :organization-deleted
:organization-id organization-id
:organization-name organization-name
:teams teams
:deleted-teams deleted-teams})))

View File

@ -135,8 +135,7 @@
;; still not deleted.
result (when (and (::deduplicate? params)
(:hash mdata)
(:bucket mdata)
(not= "tempfile" (:bucket mdata)))
(:bucket mdata))
(let [result (get-database-object-by-hash connectable backend
(:bucket mdata)
(:hash mdata))]

View File

@ -12,6 +12,7 @@
[app.common.time :as ct]
[promesa.exec :as px])
(:import
com.github.benmanes.caffeine.cache.AsyncCache
com.github.benmanes.caffeine.cache.Cache
com.github.benmanes.caffeine.cache.Caffeine
com.github.benmanes.caffeine.cache.RemovalListener
@ -46,18 +47,15 @@
:miss-rate (.missRate stats)}))
(defn create
"Build an in-memory cache. Loads run synchronously on the calling
thread, so when a load fn throws or returns nil the entry is not
stored concurrent loads for the same key still deduplicate."
[& {:keys [executor on-remove max-size keepalive expire]}]
[& {:keys [executor on-remove max-size keepalive]}]
(let [cache (as-> (Caffeine/newBuilder) builder
(if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder)
(if executor (.executor builder ^Executor (px/resolve-executor executor)) builder)
(if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) builder)
(if expire (.expireAfterWrite builder ^Duration (ct/duration expire)) builder)
(if (int? max-size) (.maximumSize builder (long max-size)) builder)
(.recordStats builder)
(.build builder))]
(.buildAsync builder))
cache (.synchronous ^AsyncCache cache)]
(reify
ICache
(get [_ k]
@ -71,7 +69,7 @@
(invalidate! [_]
(.invalidateAll ^Cache cache))
(invalidate! [_ k]
(.invalidate ^Cache cache ^Object k))
(.invalidateAll ^Cache cache ^Object k))
ICacheStats
(stats [_]

View File

@ -17,7 +17,6 @@
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.string :as str]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.request :as yreq]
@ -113,74 +112,6 @@
(t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
(defrecord MethodAwareDummyRequest [req-method headers]
yreq/IRequest
(method [_] req-method)
(get-header [_ name] (get headers name)))
(t/deftest cors-middleware-allowlisted-origin
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://trusted.example"}))
headers (::yres/headers resp)]
(t/is (= 200 (::yres/status resp)))
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
(t/is (= "true" (get headers "access-control-allow-credentials")))
(t/is (= "Origin" (get headers "vary")))
(t/is (= "content-type" (get headers "access-control-expose-headers")))
(t/is (not (str/includes?
(get headers "access-control-allow-headers" "")
"cookie")))))
(t/deftest cors-middleware-non-allowlisted-origin
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://attacker.example"}))
headers (::yres/headers resp)]
(t/is (= 200 (::yres/status resp)))
(t/is (nil? (get headers "access-control-allow-origin")))
(t/is (nil? (get headers "access-control-allow-credentials")))
(t/is (nil? (get headers "access-control-allow-headers")))
(t/is (nil? (get headers "access-control-expose-headers")))
(t/is (= "Origin" (get headers "vary")))))
(t/deftest cors-middleware-preflight-allowlisted
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://trusted.example"}))
headers (::yres/headers resp)]
(t/is (= 204 (::yres/status resp)))
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
(t/is (= "true" (get headers "access-control-allow-credentials")))))
(t/deftest cors-middleware-preflight-non-allowlisted
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://attacker.example"}))
headers (::yres/headers resp)]
(t/is (= 204 (::yres/status resp)))
(t/is (nil? (get headers "access-control-allow-origin")))
(t/is (nil? (get headers "access-control-allow-credentials")))))
(t/deftest cors-middleware-missing-origin
(let [handler (#'app.http.middleware/wrap-cors
(fn [_] {::yres/status 200 ::yres/headers {}})
#{"https://trusted.example"})
resp (handler (->MethodAwareDummyRequest :get {}))
headers (::yres/headers resp)]
(t/is (= 200 (::yres/status resp)))
(t/is (nil? (get headers "access-control-allow-origin")))
(t/is (nil? (get headers "access-control-allow-credentials")))))
(t/deftest session-authz
(let [cfg th/*system*
manager (session/inmemory-manager)

View File

@ -1,36 +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
(ns backend-tests.logical-deletion-test
(:require
[app.common.time :as ct]
[app.config :as cf]
[app.features.logical-deletion :as ldel]
[clojure.test :as t]))
(t/deftest get-deletion-delay-for-active-subscriptions
(t/is (= (ct/duration {:days 30})
(ldel/get-deletion-delay {:subscription {:type "unlimited"
:status "active"}})))
(t/is (= (ct/duration {:days 90})
(ldel/get-deletion-delay {:subscription {:type "enterprise"
:status "active"}})))
(t/is (= (ct/duration {:days 90})
(ldel/get-deletion-delay {:subscription {:type "nitrate"
:status "active"}}))))
(t/deftest get-deletion-delay-for-canceled-subscriptions
(let [fallback (ct/duration {:days 5})]
(with-redefs [cf/get-deletion-delay (fn [] fallback)]
(t/is (= fallback
(ldel/get-deletion-delay {:subscription {:type "nitrate"
:status "canceled"}})))
(t/is (= fallback
(ldel/get-deletion-delay {:subscription {:type "enterprise"
:status "unpaid"}}))))))

View File

@ -154,7 +154,7 @@
(t/is (nil? (sto/get-object storage (:media-id row1))))
(t/is (some? (sto/get-object storage (:media-id row2))))
;; check that storage object is still exists but is marked as deleted.
;; check that storage object is still exists but is marked as deleted
(let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})]
(t/is (nil? row))))))
@ -254,32 +254,6 @@
(t/is (some? (sto/get-object storage (:media-id row2)))))))
(t/deftest create-file-thumbnail-requires-edit-permissions
(let [owner (th/create-profile* 1)
viewer (th/create-profile* 2)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)
:is-shared false
:revn 1})
_ (th/create-file-role* {:file-id (:id file)
:profile-id (:id viewer)
:role :viewer})
data {::th/type :create-file-thumbnail
::rpc/profile-id (:id viewer)
:file-id (:id file)
:revn 1
:media {:filename "sample.jpg"
:size 7923
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
:mtype "image/jpeg"}}
out (th/command! data)
error (:error out)]
(t/is (nil? (:result out)))
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))
(t/is (= 0 (count (th/db-query :file-thumbnail {:file-id (:id file)}))))))
(t/deftest error-on-direct-storage-obj-deletion
(let [storage (::sto/storage th/*system*)
profile (th/create-profile* 1)

View File

@ -186,10 +186,8 @@
expected-start (str "[" (d/sanitize-string organization-name) "] ")
org-summary {:id organization-id
:name organization-name
:teams [{:id (:id team-with-files)
:is-your-penpot true}
{:id (:id empty-team)
:is-your-penpot true}]}
:teams [{:id (:id team-with-files)}
{:id (:id empty-team)}]}
calls (atom [])
submitted (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params]
@ -224,7 +222,6 @@
(let [{:keys [topic message]} (first @calls)]
(t/is (= uuid/zero topic))
(t/is (= :organization-deleted (:type message)))
(t/is (= organization-id (:organization-id message)))
(t/is (= organization-name (:organization-name message)))
(t/is (= #{(:id team-with-files) (:id empty-team)}
(set (:teams message))))
@ -257,16 +254,12 @@
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
owned-orgs [{:id org-1-id
:name org-1-name
:teams [{:id (:id org-1-team-files)
:is-your-penpot true}
{:id (:id org-1-team-empty)
:is-your-penpot true}]}
:teams [{:id (:id org-1-team-files)}
{:id (:id org-1-team-empty)}]}
{:id org-2-id
:name org-2-name
:teams [{:id (:id org-2-team-files)
:is-your-penpot true}
{:id (:id org-2-team-empty)
:is-your-penpot true}]}]
:teams [{:id (:id org-2-team-files)}
{:id (:id org-2-team-empty)}]}]
calls (atom [])
submitted (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params]
@ -320,8 +313,6 @@
m2 (org-msg org-2-name)]
(t/is (some? m1))
(t/is (some? m2))
(t/is (= org-1-id (:organization-id m1)))
(t/is (= org-2-id (:organization-id m2)))
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
(set (:teams m1))))
(t/is (= #{(:id org-1-team-empty)}
@ -570,263 +561,6 @@
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
(t/is (= 1 (count remaining-other))))))
(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations
(let [profile (th/create-profile* 1 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
org-summary {:id org-id
:teams [{:id (:id team-1)}
{:id (:id team-2)}]}
params {::th/type :delete-all-org-invitations
:organization-id org-id}]
;; Should be deleted: org-level invitation.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "alice@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: team-level invitation in team-1 (belongs to org).
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "bob@example.com"
:created-by (:id profile)
:role "admin"
:valid-until (ct/in-future "48h")})
;; Should be deleted: team-level invitation in team-2 (belongs to org),
;; even if expired.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "carol@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
;; Should remain: invitation to a team outside the org.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "dan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should remain: invitation to a different organization.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id (uuid/random)
:team-id nil
:email-to "erin@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [calls (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params]
(swap! calls conj {:method method :params params})
(case method
:get-org-summary org-summary
nil))]
(management-command-with-nitrate! params))
present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))]
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; get-org-summary was called with the right organization-id.
(t/is (= 1 (count @calls)))
(t/is (= :get-org-summary (-> @calls first :method)))
(t/is (= {:organization-id org-id} (-> @calls first :params)))
;; Org-level + team-in-org invitations are deleted.
(t/is (not (present? "alice@example.com")))
(t/is (not (present? "bob@example.com")))
(t/is (not (present? "carol@example.com")))
;; Invitations outside the org survive.
(t/is (present? "dan@example.com"))
(t/is (present? "erin@example.com")))))
(t/deftest delete-all-org-invitations-handles-org-with-no-teams
(let [profile (th/create-profile* 1 {:is-active true})
org-id (uuid/random)
params {::th/type :delete-all-org-invitations
:organization-id org-id}]
;; Org-level invitation should still be deleted.
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "alice@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
(case method
:get-org-summary {:id org-id :teams []}
nil))]
(management-command-with-nitrate! params))
remaining (th/db-query :team-invitation {:org-id org-id})]
(t/is (th/success? out))
(t/is (nil? (:result out)))
(t/is (empty? remaining)))))
(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
base-params {::th/type :exists-org-team-invitations-for-non-members
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
:member-ids [(:id member1)]}
exist! (fn [] (-> (management-command-with-nitrate! base-params)
:result
:exists))]
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "member1@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "outsider@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (false? (exist!)))
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "orphan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(t/is (true? (exist!)))))
(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
profile (th/create-profile* 4 {:is-active true})
team-1 (th/create-team* 1 {:profile-id (:id profile)})
team-2 (th/create-team* 2 {:profile-id (:id profile)})
outside-team (th/create-team* 3 {:profile-id (:id profile)})
org-id (uuid/random)
params {::th/type :delete-org-team-invitations-for-non-members
::rpc/profile-id (:id profile)
:organization-id org-id
:team-ids [(:id team-1) (:id team-2)]
:member-ids [(:id member1)]}]
;; Should remain: member1 is an org member.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "member1@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Org-level invitation remains (out of team cleanup scope).
(th/db-insert! :team-invitation
{:id (uuid/random)
:org-id org-id
:team-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: team invitation for non-member
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "pending@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: orphaned invitation
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-2)
:org-id nil
:email-to "orphan@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
;; Should be deleted: expired invitation.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team-1)
:org-id nil
:email-to "expired@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-past "1h")})
;; Should remain: outside org scope.
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id outside-team)
:org-id nil
:email-to "outsider@example.com"
:created-by (:id profile)
:role "editor"
:valid-until (ct/in-future "24h")})
(let [out (management-command-with-nitrate! params)]
(t/is (th/success? out))
(t/is (nil? (:result out)))
;; Verify remaining invitations.
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"}))))
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "pending@example.com"}))))
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"}))))
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "expired@example.com"}))))
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: remove-from-org
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -1074,8 +808,7 @@
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 0
:teams-to-exit 0
:teams-to-detach 0}
:teams-to-exit 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-delete
@ -1101,8 +834,7 @@
(t/is (th/success? out))
(t/is (= {:teams-to-delete 1
:teams-to-transfer 0
:teams-to-exit 0
:teams-to-detach 0}
:teams-to-exit 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
@ -1132,8 +864,7 @@
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 1
:teams-to-exit 0
:teams-to-detach 0}
:teams-to-exit 0}
(:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-exit
@ -1162,8 +893,7 @@
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 0
:teams-to-exit 1
:teams-to-detach 0}
:teams-to-exit 1}
(:result out)))))
(t/deftest get-remove-from-org-summary-does-not-mutate

View File

@ -19,8 +19,7 @@
[backend-tests.storage-test :refer [configure-storage-backend]]
[buddy.core.bytes :as b]
[clojure.test :as t]
[datoteka.fs :as fs]
[datoteka.io :as io]))
[datoteka.fs :as fs]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@ -40,23 +39,6 @@
(t/is (nil? (:error out)))
(:result out)))
(t/deftest upload-tempfile-returns-fresh-object-for-same-content
(let [profile (th/create-profile* 1 {:is-active true})
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
_ (io/write* path "content")
params {::th/type :upload-tempfile
::rpc/profile-id (:id profile)
:content {:filename "export.png"
:path path
:mtype "image/png"
:size 7}}
out1 (th/management-command! params)
out2 (th/management-command! params)]
(t/is (nil? (:error out1)))
(t/is (nil? (:error out2)))
(t/is (not= (get-in out1 [:result :id])
(get-in out2 [:result :id])))))
(t/deftest duplicate-file
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))

View File

@ -7,7 +7,6 @@
(ns backend-tests.rpc-nitrate-test
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
@ -45,13 +44,6 @@
:organization-id (:id org-summary)}
nil)))
(defn- nitrate-org-summary-only-mock
[org-summary]
(fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -287,64 +279,6 @@
(let [team (th/db-get :team {:id (:id team1)})]
(t/is (nil? (:deleted-at team))))))))
(t/deftest get-leave-org-summary-counts-default-team-as-delete-when-empty
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 97 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [])]
(with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)]
(let [out (th/command! {::th/type :get-leave-org-summary
::rpc/profile-id (:id profile-user)
:id organization-id
:default-team-id your-penpot-id})]
(t/is (th/success? out))
(t/is (= {:teams-to-delete 0
:teams-to-transfer 0
:teams-to-exit 0
:teams-to-detach 0}
(:result out)))))))
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})
org-default-team (th/create-team* 96 {:profile-id (:id profile-user)})
project (th/create-project* 96 {:profile-id (:id profile-user)
:team-id (:id org-default-team)})
_ (th/create-file* 96 {:profile-id (:id profile-user)
:project-id (:id project)})
extra-team (th/create-team* 95 {:profile-id (:id profile-user)})
organization-id (uuid/random)
your-penpot-id (:id org-default-team)
org-summary (make-org-summary
:organization-id organization-id
:organization-name "Test Org"
:owner-id (:id profile-owner)
:your-penpot-teams [your-penpot-id]
:org-teams [(:id extra-team)])]
(with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)]
(let [out (th/command! {::th/type :get-leave-org-summary
::rpc/profile-id (:id profile-user)
:id organization-id
:default-team-id your-penpot-id})]
(t/is (th/success? out))
;; extra-team is deletable, default team has files and is preserved.
(t/is (= {:teams-to-delete 1
:teams-to-transfer 0
:teams-to-exit 0
:teams-to-detach 1}
(:result out)))))))
(t/deftest leave-org-error-org-owner-cannot-leave
(let [profile-owner (th/create-profile* 1 {:is-active true})
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
@ -716,71 +650,6 @@
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
(t/deftest all-team-members-in-orgs-returns-org-id->boolean-map
(let [profile-user (th/create-profile* 201 {:is-active true})
profile-other (th/create-profile* 202 {:is-active true})
team (th/create-team* 201 {:profile-id (:id profile-user)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id profile-other)
:role :editor})
team-member-ids (->> (th/db-query :team-profile-rel {:team-id (:id team)})
(map :profile-id)
(into #{}))
org-id-1 (uuid/random)
org-id-2 (uuid/random)
calls (atom [])]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (fn [_cfg method params]
(swap! calls conj [method params])
(case method
:get-org-membership {:is-member true
:organization-id (:organization-id params)}
:get-org-members (get {org-id-1 (vec team-member-ids)
org-id-2 [(:id profile-user)]}
(:organization-id params)
[])
nil))]
(let [out (th/command! {::th/type :all-team-members-in-orgs
::rpc/profile-id (:id profile-user)
:team-id (:id team)
:organization-ids [org-id-1 org-id-2]})
methods (map first @calls)
membership-calls (count (filter #(= :get-org-membership %) methods))
get-members-calls (count (filter #(= :get-org-members %) methods))]
(t/is (th/success? out))
(t/is (= {org-id-1 true
org-id-2 false}
(:result out)))
(t/is (= 2 membership-calls))
(t/is (= 2 get-members-calls))))))
(t/deftest all-team-members-in-orgs-fails-before-fetching-org-members
(let [profile-user (th/create-profile* 203 {:is-active true})
team (th/create-team* 203 {:profile-id (:id profile-user)})
org-id-1 (uuid/random)
org-id-2 (uuid/random)
calls (atom [])]
(with-redefs [cf/flags (conj cf/flags :nitrate)
nitrate/call (fn [_cfg method params]
(swap! calls conj [method params])
(case method
:get-org-membership (if (= (:organization-id params) org-id-2)
{:is-member false
:organization-id (:organization-id params)}
{:is-member true
:organization-id (:organization-id params)})
:get-org-members []
nil))]
(let [out (th/command! {::th/type :all-team-members-in-orgs
::rpc/profile-id (:id profile-user)
:team-id (:id team)
:organization-ids [org-id-1 org-id-2]})
methods (map first @calls)]
(t/is (not (th/success? out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :user-doesnt-belong-organization (th/ex-code (:error out))))
(t/is (= 0 (count (filter #(= :get-org-members %) methods))))))))
(t/deftest leave-org-error-reassign-on-non-owned-team
(let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {:is-active true})

View File

@ -48,23 +48,6 @@
(t/is (= "content" (slurp (sto/get-object-data storage object))))
(t/is (= "content" (slurp (sto/get-object-path storage object))))))
(t/deftest tempfile-objects-are-not-deduplicated
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
content (-> (sto/content "content")
(sto/wrap-with-hash "same-hash"))
object1 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:bucket "tempfile"
:content-type "text/plain"})
object2 (sto/put-object! storage {::sto/content content
::sto/deduplicate? true
::sto/touched-at (ct/in-future {:minutes 10})
:bucket "tempfile"
:content-type "text/plain"})]
(t/is (not= (:id object1) (:id object2)))))
(t/deftest put-and-retrieve-expired-object
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))

View File

@ -30,7 +30,6 @@
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test:js": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:jvm": "clojure -M:dev:test"
}
}

View File

@ -1,25 +0,0 @@
import { spawnSync } from "node:child_process";
const progress = (msg) => process.stderr.write(`${msg}\n`);
progress("Building test bundle...");
const build = spawnSync("pnpm", ["run", "build:test"], {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (build.status !== 0) {
progress("Building test bundle failed");
if (build.stdout?.length) process.stdout.write(build.stdout);
if (build.stderr?.length) process.stderr.write(build.stderr);
process.exit(build.status ?? 1);
}
progress("Running tests...");
const result = spawnSync(
"node",
["target/tests/test.js", ...process.argv.slice(2)],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);

View File

@ -332,7 +332,10 @@
(conj opacity)))
(defn hex->hsl [hex]
(-> hex hex->rgb rgb->hsl))
(try
(-> hex hex->rgb rgb->hsl)
(catch #?(:clj Throwable :cljs :default) _e
[0 0 0])))
(defn hex->hsla
[data opacity]

View File

@ -17,7 +17,7 @@
(defmacro select-keys
"A macro version of `select-keys`. Useful when keys vector is known
at compile time (approx 600% performance boost).
at compile time (aprox 600% performance boost).
It is not 100% equivalent, this macro does not removes not existing
keys in contrast to clojure.core/select-keys"

View File

@ -1194,7 +1194,7 @@
;; frames. Return the ids of the frames affected
(defn- parents-frames
"Go through the parents and get all of them that are a frame."
"Go trough the parents and get all of them that are a frame."
[id objects]
(->> (cfh/get-parents-with-self objects id)
(filter cfh/frame-shape?)))

View File

@ -19,7 +19,6 @@
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid]
@ -413,9 +412,12 @@
(add-object changes obj nil))
([changes obj {:keys [index ignore-touched] :or {index ::undefined ignore-touched false}}]
;; FIXME: add shape validation
(assert-page-id! changes)
(assert-objects! changes)
(let [obj (cond-> (cts/check-shape obj)
(let [obj (cond-> obj
(not= index ::undefined)
(assoc ::index index))

View File

@ -13,7 +13,7 @@
(defn- generate-index
"An optimized algorithm for calculate parents index that walk from top
to down starting from a provided shape-id. Useful when you want to
to down starting from a provided shape-id. Usefull when you want to
create an index for the whole objects or subpart of the tree."
[index objects shape-id parents]
(let [shape (get objects shape-id)

View File

@ -34,7 +34,7 @@
[app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctst]
[app.common.types.text :as types.text]
[app.common.types.tokens-lib :as ctob]
[app.common.types.tokens-lib :as types.tokens-lib]
[app.common.uuid :as uuid]
[clojure.set :as set]
[cuerdas.core :as str]))
@ -1599,7 +1599,7 @@
(defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids"
[data _]
(d/update-when data :tokens-lib ctob/fix-duplicate-token-set-ids))
(d/update-when data :tokens-lib types.tokens-lib/fix-duplicate-token-set-ids))
(defmethod migrate-data "0014-clear-components-nil-objects"
[data _]
@ -1833,47 +1833,6 @@
(cfcp/fix-missing-swap-slots libraries)
(cfcp/sync-component-id-with-ref-shape libraries))))
(defmethod migrate-data "0023-repair-token-themes-with-inexistent-sets"
[data _]
(d/update-when data :tokens-lib ctob/fix-missing-sets-in-themes))
;; This will fix incorrectly created strokes from SVG imports
;; that have the stroke-cap at the shape level instead of at the stroke level
(defmethod migrate-data "0024b-fix-stroke-cap-placement"
[data _]
(letfn [(check-strokes [strokes]
(->> strokes
(mapv (fn [stroke]
(cond-> stroke
(string? (:stroke-cap-start stroke))
(update :stroke-cap-start keyword)
(string? (:stroke-cap-end stroke))
(update :stroke-cap-end keyword))))))
(fix-shape [shape]
(let [cap-start (keyword (get shape :stroke-cap-start))
cap-end (keyword (get shape :stroke-cap-end))]
(if (or (some? cap-start) (some? cap-end))
(-> shape
(dissoc :stroke-cap-start :stroke-cap-end)
(cond-> (seq (:strokes shape))
(update :strokes check-strokes)
(and (some? cap-start) (seq (:strokes shape)))
(assoc-in [:strokes 0 :stroke-cap-start] cap-start)
(and (some? cap-end) (seq (:strokes shape)))
(assoc-in [:strokes 0 :stroke-cap-end] cap-end)))
shape)))
(update-container [container]
(d/update-when container :objects d/update-vals fix-shape))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@ -1953,6 +1912,4 @@
"0019-fix-missing-swap-slots"
"0020-sync-component-id-with-near-main"
"0021-fix-shape-svg-attrs"
"0022-normalize-component-root-and-resync"
"0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"]))
"0022-normalize-component-root-and-resync"]))

View File

@ -543,7 +543,7 @@
(update :svg-attrs dissoc :fill)
(assoc-in [:fills 0 :fill-color] (clr/parse color-style)))
;; Only create an opacity if the color is set. Otherwise can create problems down the line
;; Only create an opacity if the color is setted. Othewise can create problems down the line
(and (or (clr/color-string? color-attr) (clr/color-string? color-style))
(dm/get-in shape [:svg-attrs :fillOpacity]))
(-> (update :svg-attrs dissoc :fillOpacity)
@ -609,13 +609,17 @@
(and (some? color) (some? width))
(assoc-in [:strokes 0 :stroke-width] width)
(and (some? color) (some? linecap) (cfh/path-shape? shape)
(and (some? linecap) (cfh/path-shape? shape)
(or (= linecap :round) (= linecap :square)))
(assoc-in [:strokes 0 :stroke-cap-start] linecap)
(and (some? color) (some? linecap) (cfh/path-shape? shape)
(or (= linecap :round) (= linecap :square)))
(assoc-in [:strokes 0 :stroke-cap-end] linecap))))
(assoc :stroke-cap-start linecap
:stroke-cap-end linecap
:stroke-linecap linecap)
(d/any-key? (dm/get-in shape [:strokes 0])
:strokeColor :strokeOpacity :strokeWidth
:strokeLinecap :strokeCapStart :strokeCapEnd)
(assoc-in [:strokes 0 :stroke-style] :svg))))
(defn setup-opacity [shape]
(cond-> shape

View File

@ -148,9 +148,11 @@
(not (ctob/token-name-path-exists? % tokens-tree)))]])
(defn make-node-token-name-schema
"Dynamically generates a schema to check the name of a token node, that may be a final token or a group.
This runs same checks as make-token-name-schema, but for all tokens that will be renamed by this change,
if the group already contains tokens."
"Dynamically generates a schema to check a token node name, adding translated error messages
and two additional validations:
- Min and max length.
- Checks if other token with a path derived from the name already exists at `tokens-tree`.
e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists."
[active-tokens tokens-tree node]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
@ -285,18 +287,12 @@
(defn make-token-theme-schema
[tokens-lib group name theme-id]
[:and
(sm/merge
ctob/schema:token-theme-attrs
[:map
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]])
[:fn {:error/field :sets
:error/fn #(tr "errors.token-theme-not-existing-sets" (str/join ", " (:sets (:value %))))}
(fn [{:keys [sets]}]
(or (nil? tokens-lib)
(every? #(ctob/get-set-by-name tokens-lib %) sets)))]])
(sm/merge
ctob/schema:token-theme-attrs
[:map
[:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[:name (make-token-theme-name-schema tokens-lib group theme-id)]
[:description {:optional true} schema:token-theme-description]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS

View File

@ -591,7 +591,7 @@
-it should be a main component
-its parent should be a variant-container
-its variant-name is derived from the properties
-its name should be the same as its parent's
-its name should be tha same as its parent's
"
[shape file page]
(let [parent (ctst/get-shape page (:parent-id shape))
@ -707,7 +707,7 @@
(if (#{:main-top :main-nested :main-any} context)
(report-error :not-component-not-allowed
"Not components are not allowed inside a main"
"Not compoments are not allowed inside a main"
shape file page)
(check-shape-not-component shape file page libraries)))))))))

View File

@ -11,7 +11,7 @@
[app.common.types.variant :as ctv]))
(defn find-variant-components
"Find a list of the components that belongs to this variant-id"
"Find a list of the components thet belongs to this variant-id"
([data variant-id]
(let [page-id (->> data
:components

View File

@ -72,11 +72,7 @@
:backend-worker
;; Only for development
:component-thumbnails
;; Enables CORS support for the RPC API. Requires an explicit
;; allowlist of origins via PENPOT_ALLOWED_ORIGINS; if no allowlist
;; is configured the middleware fails closed (a warning is logged
;; and CORS headers are not emitted) to avoid CSRF / data
;; exfiltration via origin reflection.
;; enables the default cors configuration that allows all domains (currently this configuration is only used for development).
:cors
;; Enables the templates dialog on Penpot dashboard.
:dashboard-templates-section
@ -169,7 +165,6 @@
:mcp
:background-blur
:available-viewer-wasm
:stroke-path})
(def all-flags

View File

@ -34,7 +34,7 @@
;; modif-tree))))
(defn- set-children-modifiers
"Propagates the modifiers from a parent too its children applying constraints if necessary"
"Propagates the modifiers from a parent too its children applying constraints if necesary"
[modif-tree children objects bounds parent transformed-parent-bounds ignore-constraints]
(let [modifiers (dm/get-in modif-tree [(:id parent) :modifiers])]
;; Move modifiers don't need to calculate constraints

View File

@ -11,8 +11,7 @@
[app.common.files.helpers :as cfh]
[app.common.geom.rect :as grc]
[app.common.math :as mth]
[app.common.types.path :as path]
[app.common.types.stroke :as cts]))
[app.common.types.path :as path]))
(defn shape-stroke-margin
[shape stroke-width]
@ -89,23 +88,14 @@
([shape]
(get-shape-filter-bounds shape false))
([shape ignore-shadow-margin?]
(cond
;; SVG raw elements (non-root) don't have proper rotated points; use selrect
(and (cfh/svg-raw-shape? shape)
(not= :svg (dm/get-in shape [:content :tag])))
(if (or (and (cfh/svg-raw-shape? shape)
(not= :svg (dm/get-in shape [:content :tag])))
;; If no shadows or blur, we return the selrect as is
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0))))))
(dm/get-prop shape :selrect)
;; No shadows or blur: use the axis-aligned bounding box from the actual
;; (possibly rotated) points. Using selrect here would be wrong for rotated
;; shapes because selrect stores the unrotated rectangle, not the screen-space bbox.
(and (empty? (-> shape :shadow))
(or (nil? (:blur shape))
(not= :layer-blur (-> shape :blur :type))
(zero? (-> shape :blur :value (or 0)))))
(-> (dm/get-prop shape :points)
(grc/points->rect))
:else
(let [filters (shape->filters shape)
blur-value (case (-> shape :blur :type)
:layer-blur (or (-> shape :blur :value) 0)
@ -115,19 +105,6 @@
(grc/points->rect))]
(get-rect-filter-bounds srect filters blur-value ignore-shadow-margin?)))))
(def ^:private stroke-margin-multiplier 4.25)
(defn- stroke-cap-marker-margin
[strokes open-path?]
(if open-path?
(->> strokes
(filter (fn [s]
(or (cts/stroke-caps-marker (:stroke-cap-start s))
(cts/stroke-caps-marker (:stroke-cap-end s)))))
(map #(* stroke-margin-multiplier (:stroke-width % 0)))
(reduce d/max 0))
0))
(defn calculate-padding
([shape]
(calculate-padding shape false false))
@ -150,11 +127,6 @@
0
(shape-stroke-margin shape stroke-width))
stroke-cap-margin
(if ignore-margin?
0
(stroke-cap-marker-margin strokes open-path?))
shadow-width
(->> (:shadow shape)
(remove :hidden)
@ -177,8 +149,8 @@
shadow-width
(if ignore-shadow-margin? 0 shadow-width)]
{:horizontal (mth/ceil (+ stroke-margin stroke-cap-margin shadow-width))
:vertical (mth/ceil (+ stroke-margin stroke-cap-margin shadow-height))})))
{:horizontal (mth/ceil (+ stroke-margin shadow-width))
:vertical (mth/ceil (+ stroke-margin shadow-height))})))
(defn- add-padding
[bounds padding]

View File

@ -264,7 +264,7 @@
:scale)))
(defn normalize-modifiers
"Before applying constraints we need to remove the deformation caused by the resizing of the parent"
"Before aplying constraints we need to remove the deformation caused by the resizing of the parent"
[constraints-h constraints-v modifiers
child-bounds transformed-child-bounds parent-bounds transformed-parent-bounds]

View File

@ -12,7 +12,7 @@
[app.common.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl]))
;; Set in app.common.geom.shapes.common-layout
;; Setted in app.common.geom.shapes.common-layout
;; We do it this way because circular dependencies
(def -child-min-width nil)

View File

@ -14,7 +14,7 @@
(def conjv (fnil conj []))
;; Set in app.common.geom.shapes.min-size-layout
;; Setted in app.common.geom.shapes.min-size-layout
;; We do it this way because circular dependencies
(def -child-min-width nil)

View File

@ -39,7 +39,7 @@
;;
;; 5. If any track still has an infinite growth limit set its growth limit to its base size.
;; - Distribute extra space across spanned tracks
;; - Distribute extra space accross spaned tracks
;; - Maximize tracks
;;
;; - Expand flexible tracks
@ -55,7 +55,7 @@
[app.common.math :as mth]
[app.common.types.shape.layout :as ctl]))
;; Set in app.common.geom.shapes.common-layout
;; Setted in app.common.geom.shapes.common-layout
;; We do it this way because circular dependencies
(def -child-min-width nil)
@ -449,7 +449,7 @@
column-tracks (set-auto-base-size column-tracks children shape-cells bounds objects :column)
row-tracks (set-auto-base-size row-tracks children shape-cells bounds objects :row)
;; Adjust multi-spanned cells with no flex columns
;; Adjust multi-spaned cells with no flex columns
column-tracks (set-auto-multi-span parent column-tracks children-map shape-cells bounds objects :column)
row-tracks (set-auto-multi-span parent row-tracks children-map shape-cells bounds objects :row)

View File

@ -369,7 +369,7 @@
(defn line-line-intersect
"Calculates the intersection point for two lines given by the points a-b and b-c"
"Calculates the interesection point for two lines given by the points a-b and b-c"
[a b c d]
(let [;; Line equation representation: ax + by + c = 0

View File

@ -31,21 +31,21 @@
(gpt/scale val)))
(defn end-hv
"Horizontal vector from the opposite to the origin in the x axis with a magnitude `val`"
"Horizontal vector from the oposite to the origin in the x axis with a magnitude `val`"
[[p0 p1 _ _] val]
(-> (gpt/to-vec p1 p0)
(gpt/unit)
(gpt/scale val)))
(defn start-vv
"Vertical vector from the opposite to the origin in the x axis with a magnitude `val`"
"Vertical vector from the oposite to the origin in the x axis with a magnitude `val`"
[[p0 _ _ p3] val]
(-> (gpt/to-vec p0 p3)
(gpt/unit)
(gpt/scale val)))
(defn end-vv
"Vertical vector from the opposite to the origin in the x axis with a magnitude `val`"
"Vertical vector from the oposite to the origin in the x axis with a magnitude `val`"
[[p0 _ _ p3] val]
(-> (gpt/to-vec p3 p0)
(gpt/unit)

View File

@ -283,7 +283,7 @@
[selrect transform (when (some? transform) (gmt/inverse transform))]))
(defn- adjust-shape-flips
"After some transformations the flip-x/flip-y flags can change we need
"After some tranformations the flip-x/flip-y flags can change we need
to check this before adjusting the selrect"
[shape points]
(let [points' (dm/get-prop shape :points)

View File

@ -90,7 +90,7 @@
child-seq)))
(defn resolve-subtree
"Resolves the subtree but only partially from-to the parameters"
"Resolves the subtree but only partialy from-to the parameters"
[from-id to-id objects]
(concat
(->> (get-children-seq from-id objects)

View File

@ -486,41 +486,36 @@
that use assets of the given type in the given library.
If an asset id is given, only shapes linked to this particular asset will
be synchronized.
be synchronized."
[changes file-id asset-type asset-id library-id libraries current-file-id]
(assert (contains? #{:colors :components :typographies} asset-type))
(assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
If early-return? is true, stops as soon as the first change is generated."
([changes file-id asset-type asset-id library-id libraries current-file-id]
(generate-sync-file changes file-id asset-type asset-id library-id libraries current-file-id false))
([changes file-id asset-type asset-id library-id libraries current-file-id early-return?]
(assert (contains? #{:colors :components :typographies} asset-type))
(assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
(container-log :info asset-id
:msg "Sync file with library"
:asset-type asset-type
:asset-id asset-id
:file (pretty-file file-id libraries current-file-id)
:library (pretty-file library-id libraries current-file-id))
(container-log :info asset-id
:msg "Sync file with library"
:asset-type asset-type
:asset-id asset-id
:file (pretty-file file-id libraries current-file-id)
:library (pretty-file library-id libraries current-file-id))
(let [file (get-in libraries [file-id :data])]
(loop [containers (ctf/object-containers-seq file)
changes changes]
(let [container (first containers)]
(if (or (nil? container)
(and early-return? (seq (:redo-changes changes))))
changes
(recur (next containers)
(pcb/concat-changes ;;TODO Remove concat changes
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
container
libraries
current-file-id)))))))))
(let [file (get-in libraries [file-id :data])]
(loop [containers (ctf/object-containers-seq file)
changes changes]
(if-let [container (first containers)]
(do
(recur (next containers)
(pcb/concat-changes ;;TODO Remove concat changes
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
container
libraries
current-file-id))))
changes))))
(defn generate-sync-library
"Generate changes to synchronize all shapes in all components of the
@ -528,41 +523,35 @@
the given library.
If an asset id is given, only shapes linked to this particular asset will
be synchronized.
be synchronized."
[changes file-id asset-type asset-id library-id libraries current-file-id]
(assert (contains? #{:colors :components :typographies} asset-type))
(assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
If early-return? is true, stops as soon as the first change is generated."
([changes file-id asset-type asset-id library-id libraries current-file-id]
(generate-sync-library changes file-id asset-type asset-id library-id libraries current-file-id false))
([changes file-id asset-type asset-id library-id libraries current-file-id early-return?]
(assert (contains? #{:colors :components :typographies} asset-type))
(assert (or (nil? asset-id) (uuid? asset-id)))
(assert (uuid? file-id))
(assert (uuid? library-id))
(container-log :info asset-id
:msg "Sync local components with library"
:asset-type asset-type
:asset-id asset-id
:file (pretty-file file-id libraries current-file-id)
:library (pretty-file library-id libraries current-file-id))
(container-log :info asset-id
:msg "Sync local components with library"
:asset-type asset-type
:asset-id asset-id
:file (pretty-file file-id libraries current-file-id)
:library (pretty-file library-id libraries current-file-id))
(let [file (get-in libraries [file-id :data])]
(loop [local-components (ctkl/components-seq file)
changes changes]
(let [local-component (first local-components)]
(if (or (nil? local-component)
(and early-return? (seq (:redo-changes changes))))
changes
(recur (next local-components)
(pcb/concat-changes ;;TODO Remove concat changes
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
(cfh/make-container local-component :component)
libraries
current-file-id)))))))))
(let [file (get-in libraries [file-id :data])]
(loop [local-components (ctkl/components-seq file)
changes changes]
(if-let [local-component (first local-components)]
(recur (next local-components)
(pcb/concat-changes ;;TODO Remove concat changes
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
(cfh/make-container local-component :component)
libraries
current-file-id)))
changes))))
(defn- generate-sync-container
"Generate changes to synchronize all shapes in a particular container (a page
@ -1862,7 +1851,7 @@
;; On texts, when we want to omit the touched attrs, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content.
;; If only one of them is touched, we want to address this case and
;; If only one of them is touched, we want to adress this case and
;; only update the untouched one
text-content-change?
(and omit-touched?
@ -2102,38 +2091,6 @@
(or (:transform current-shape) (gmt/matrix)))))))
(defn- switch-geom-change-value
[prev-shape current-shape attr]
;; Composite geometry stores absolute coordinates. When preserving a size
;; override across variants, keep the target variant's position and only carry
;; the previous dimensions; otherwise :x/:y can disagree with :selrect/:points.
(let [prev-selrect (:selrect prev-shape)
current-selrect (:selrect current-shape)
final-width (:width prev-selrect)
final-height (:height prev-selrect)
x (:x current-selrect)
y (:y current-selrect)
selrect (assoc current-selrect
:width final-width
:height final-height
:x x
:y y
:x1 x
:y1 y
:x2 (+ x final-width)
:y2 (+ y final-height))]
(case attr
:selrect
selrect
:points
(-> selrect
(grc/rect->points)
(gsh/transform-points
(grc/rect->center selrect)
(or (:transform current-shape) (gmt/matrix)))))))
(defn- equal-geometry?
"Returns true when the value of `attr` in `shape` is considered equal
to the corresponding value in `origin-shape`, ignoring positional
@ -2238,7 +2195,7 @@
;; On texts, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content.
;; If only one of them is touched, we want to address this case and
;; If only one of them is touched, we want to adress this case and
;; only update the untouched one
text-change?
(and (not skip-operations?)
@ -2303,10 +2260,6 @@
(contains? #{:points :selrect :width :height} attr))
(switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr)
(and (contains? #{:points :selrect} attr)
(not path-change?))
(switch-geom-change-value previous-shape current-shape attr)
:else
(get previous-shape attr)))
@ -2714,30 +2667,29 @@
(generate-new-shape-for-swap shape file page libraries id-new-component index target-cell keep-props-values))]
[new-shape all-parents changes]))
(defn- maybe-sync
[c enabled? done? f]
(if (and enabled? (not (done? c)))
(f c)
c))
(defn generate-sync-file-changes
([changes undo-group asset-type file-id asset-id library-id libraries current-file-id]
(generate-sync-file-changes changes undo-group asset-type file-id asset-id library-id libraries current-file-id false))
([changes undo-group asset-type file-id asset-id library-id libraries current-file-id early-return?]
(let [sync-components? (or (nil? asset-type) (= asset-type :components))
sync-colors? (or (nil? asset-type) (= asset-type :colors))
sync-typographies? (or (nil? asset-type) (= asset-type :typographies))
done? (fn [c] (and early-return? (seq (:redo-changes c))))]
(-> (pcb/set-undo-group changes undo-group)
;; library-changes
(maybe-sync sync-components? done? #(generate-sync-library % file-id :components asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-colors? done? #(generate-sync-library % file-id :colors asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-typographies? done? #(generate-sync-library % file-id :typographies asset-id library-id libraries current-file-id early-return?))
;; file-changes
(maybe-sync sync-components? done? #(generate-sync-file % file-id :components asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-colors? done? #(generate-sync-file % file-id :colors asset-id library-id libraries current-file-id early-return?))
(maybe-sync sync-typographies? done? #(generate-sync-file % file-id :typographies asset-id library-id libraries current-file-id early-return?))))))
[changes undo-group asset-type file-id asset-id library-id libraries current-file-id]
(let [sync-components? (or (nil? asset-type) (= asset-type :components))
sync-colors? (or (nil? asset-type) (= asset-type :colors))
sync-typographies? (or (nil? asset-type) (= asset-type :typographies))]
(cond-> changes
:always
(pcb/set-undo-group undo-group)
;; library-changes
sync-components?
(generate-sync-library file-id :components asset-id library-id libraries current-file-id)
sync-colors?
(generate-sync-library file-id :colors asset-id library-id libraries current-file-id)
sync-typographies?
(generate-sync-library file-id :typographies asset-id library-id libraries current-file-id)
;; file-changes
sync-components?
(generate-sync-file file-id :components asset-id library-id libraries current-file-id)
sync-colors?
(generate-sync-file file-id :colors asset-id library-id libraries current-file-id)
sync-typographies?
(generate-sync-file file-id :typographies asset-id library-id libraries current-file-id))))
(defn generate-sync-head
[changes file-full libraries container id reset?]

View File

@ -539,12 +539,5 @@
(update shape :interactions ctsi/add-interaction interaction))
(defn show-in-viewer
"Auto-unhide the shape in viewer when it becomes an interaction
destination, but only when the user has not explicitly hidden it.
Preserves explicit `:hide-in-viewer true` so that adding or updating
an interaction whose destination has been deliberately hidden does not
silently flip the viewer-visibility flag the user set. See #9049."
[shape]
(if (true? (:hide-in-viewer shape))
shape
(dissoc shape :hide-in-viewer)))
(dissoc shape :hide-in-viewer))

View File

@ -202,24 +202,6 @@
(zero? result) false
:else false)))
(defn is-after-or-equal?
"Analgous to: da >= db"
[da db]
(let [result (compare da db)]
(cond
(neg? result) false
(zero? result) true
:else true)))
(defn is-before-or-equal?
"Analgous to: da <= db"
[da db]
(let [result (compare da db)]
(cond
(neg? result) true
(zero? result) true
:else false)))
(defn inst?
[o]
#?(:clj (instance? Instant o)

View File

@ -159,7 +159,7 @@
group)))
(defn component-attr?
"Check if some attribute is one that is involved in component synchronization.
"Check if some attribute is one that is involved in component syncrhonization.
Note that design tokens also are involved, although they go by an alternate
route and thus they are not part of :sync-attrs.
Also when detaching a nested copy it also needs to trigger a synchronization,

View File

@ -259,7 +259,7 @@
(some? (find-component-main objects shape only-direct-child?))))
(defn in-any-component?
"Check if the shape is part of any component (main or copy), whether it's
"Check if the shape is part of any component (main or copy), wether it's
head or not."
[objects shape]
(or (ctk/in-component-copy? shape)
@ -405,7 +405,7 @@
(map remap-ids new-shapes)])))
(defn get-first-valid-parent
"Go through the parents until we find a shape that is not a copy of a component nor
"Go trough the parents until we find a shape that is not a copy of a component nor
a variant container."
[objects id]
(let [shape (get objects id)]
@ -517,7 +517,7 @@
:any-main-descendant any-main-descendant}))
(defn find-valid-parent-and-frame-ids
"Navigate through the ancestors until find one that is valid. Returns [ parent-id frame-id ]"
"Navigate trough the ancestors until find one that is valid. Returns [ parent-id frame-id ]"
([parent-id objects children]
(find-valid-parent-and-frame-ids parent-id objects children false nil nil))
([parent-id objects children pasting? libraries]

View File

@ -1,103 +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
(ns app.common.types.nitrate-permissions)
(def ^:private defaults
{:create-teams "any"
:delete-teams "onlyOwners"
:move-teams "always"
:send-invitations "ownersAndAdmins"
:new-team-members "anyone"})
(defn- can-create-team?
[{:keys [is-org-owner? permission-value]}]
(or is-org-owner?
(= permission-value "any")))
(defn- can-delete-team?
[{:keys [is-org-owner? permission-value team-perms]}]
(cond
;; Org owners can always delete teams inside their organizations.
is-org-owner?
true
(= permission-value "onlyOwners")
(boolean (:is-owner team-perms))
:else false))
(defn- can-move-team?
[{:keys [permission-value target-org-same-owner?]}]
(cond
(= permission-value "never")
false
(= permission-value "always")
true
(= permission-value "myOrganizations")
(true? target-org-same-owner?)
:else false))
(defn- can-invite-to-team?
[{:keys [permission-value team-perms]}]
(cond
(= permission-value "ownersAndAdmins")
(or (boolean (:is-owner team-perms))
(boolean (:is-admin team-perms)))
(= permission-value "owners")
(boolean (:is-owner team-perms))
:else false))
(defn- can-add-anybody-to-team?
[{:keys [permission-value]}]
(= permission-value "anyone"))
(def ^:private action-rules
{:create-team {:permission-key :create-teams
:check-fn can-create-team?}
:delete-team {:permission-key :delete-teams
:check-fn can-delete-team?}
:move-team {:permission-key :move-teams
:check-fn can-move-team?}
:send-invitations {:permission-key :send-invitations
:check-fn can-invite-to-team?}
:add-anybody-to-team {:permission-key :new-team-members
:check-fn can-add-anybody-to-team?}})
(defn- normalize-org-permissions
[org-perms]
(merge defaults (or (:permissions org-perms) {})))
(defn- owner?
[org-perms profile-id]
(= profile-id (:owner-id org-perms)))
(defn allowed?
"Returns true only for explicitly allowed actions (fail-closed)."
[action {:keys [org-perms profile-id team-perms target-org-same-owner?]}]
(let [{:keys [permission-key check-fn] :as rule}
(get action-rules action)
permissions (normalize-org-permissions org-perms)
is-org-owner? (owner? org-perms profile-id)
permission-value (get permissions permission-key)]
(cond
(nil? rule) false
:else (boolean (check-fn {:is-org-owner? is-org-owner?
:permission-value permission-value
:team-perms team-perms
:target-org-same-owner? target-org-same-owner?})))))
(defn can-send-invitations?
[{:keys [nitrate-enabled? organization profile-id team-permissions]}]
(let [in-org? (and nitrate-enabled? organization)]
(if in-org?
(allowed? :send-invitations
{:org-perms {:owner-id (:owner-id organization)
:permissions (:permissions organization)}
:profile-id profile-id
:team-perms team-permissions})
(or (boolean (:is-owner team-permissions))
(boolean (:is-admin team-permissions))))))

View File

@ -8,10 +8,10 @@
"Implements a specialized map-like data structure for store an UUID =>
OBJECT mappings. The main purpose of this data structure is be able
to serialize it on fressian as byte-array and have the ability to
decode each field separately without the need to decode the whole
decode each field separatelly without the need to decode the whole
map from the byte-array.
It works transparently, so no additional dynamic vars are needed. It
It works transparently, so no aditional dynamic vars are needed. It
only works by reference equality and the hash-code is calculated
properly from each value."

View File

@ -15,14 +15,7 @@
[:slug ::sm/text]
[:owner-id ::sm/uuid]
[:avatar-bg-url ::sm/uri]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:expired-license {:optional true} [:maybe :boolean]]
[:permissions {:optional true}
[:maybe [:map
[:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]
[:delete-teams {:optional true} [:maybe [:enum "onlyMe" "onlyOwners"]]]
[:move-teams {:optional true} [:maybe [:enum "always" "myOrganizations" "never"]]]
[:new-team-members {:optional true} [:maybe [:enum "anyone" "members"]]]]]]])
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
(def schema:team-with-organization
@ -32,32 +25,26 @@
[:organization schema:organization]])
(def organization->team-keys
"Organization field keys to include in the nested :organization map."
[:id :name :custom-photo :slug :avatar-bg-url :owner-id :expired-license :permissions])
"Mapping from organization field keys to their corresponding :organization-* team keys."
[[:id :organization-id]
[:name :organization-name]
[:custom-photo :organization-custom-photo]
[:slug :organization-slug]
[:avatar-bg-url :organization-avatar-bg-url]
[:owner-id :organization-owner-id]])
(defn apply-organization
"Updates a team map with organization fields in a nested :organization map.
Associates each org field within :organization when the value is non-nil;
dissociates the field otherwise. This correctly handles both attaching an org
(all values present) and detaching one (org is nil or all fields absent)."
"Updates a team map with organization fields sourced from org.
Associates each org field to the corresponding :organization-* team key when
the value is non-nil; dissociates the key otherwise. This correctly handles
both attaching an org (all values present) and detaching one (org is nil or
all fields absent)."
[team organization]
(let [id (:id organization)]
(if id
(assoc team :organization
(reduce (fn [acc k]
(let [v (get organization k)]
(if (some? v)
(assoc acc k v)
(dissoc acc k))))
(or (:organization team) {})
organization->team-keys))
(dissoc team :organization))))
(def schema:organization-with-avatar
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo [:maybe ::sm/uri]]
[:avatar-bg-url [:maybe ::sm/uri]]])
(reduce (fn [acc [org-k team-k]]
(let [v (get organization org-k)]
(if (and id (some? v))
(assoc acc team-k v)
(dissoc acc team-k))))
team
organization->team-keys)))

View File

@ -10,7 +10,7 @@
This NS allows separate context-less/dependency-less helpers from
other path related namespaces and make proper domain-specific
namespaces without incurrying on circular dependency cycles."
namespaces without incurrying on circular depedency cycles."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
@ -192,7 +192,7 @@
(defn solve-roots*
"Solvers a quadratic or cubic equation given by the parameters a b c d.
Implemented as reduction algorithm (this helps implement
Implemented as reduction algorithm (this helps implemement
derivative algorithms that does not require intermediate results
thanks to transducers."
[result conj a b c d]
@ -794,3 +794,5 @@
#_:else false))]
(some inside-border? content)))

View File

@ -30,7 +30,6 @@
[app.common.types.shape.layout :as ctsl]
[app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctsx]
[app.common.types.stroke :as stroke]
[app.common.types.text :as txt]
[app.common.types.token :as cto]
[app.common.types.variant :as ctv]
@ -62,8 +61,8 @@
(map->Shape attrs))
:clj (map->Shape attrs)))
(def stroke-caps-line stroke/stroke-caps-line)
(def stroke-caps-marker stroke/stroke-caps-marker)
(def stroke-caps-line #{:round :square})
(def stroke-caps-marker #{:line-arrow :triangle-arrow :square-marker :circle-marker :diamond-marker})
(def stroke-caps (conj (set/union stroke-caps-line stroke-caps-marker) nil))
(def shape-types
@ -138,8 +137,6 @@
[:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number]
[:stroke-dash {:optional true} ::sm/safe-number]
[:stroke-gap {:optional true} ::sm/safe-number]
[:stroke-alignment {:optional true}
[::sm/one-of #{:center :inner :outer}]]
[:stroke-cap-start {:optional true}
@ -526,7 +523,7 @@
:fills []
:strokes [{:stroke-style :solid
:stroke-alignment :inner
:stroke-width 1
:stroke-width 2
:stroke-color clr/black
:stroke-opacity 1}]})
@ -730,7 +727,7 @@
(cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape))))))
(defn patch-props
"Given the object of `extract-props` applies it to a shape. Adapt the shape if necessary"
"Given the object of `extract-props` applies it to a shape. Adapt the shape if necesary"
[shape props objects]
(letfn [(patch-text-props [shape props]

Some files were not shown because too many files have changed in this diff Show More