Compare commits

..

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

493 changed files with 59634 additions and 67520 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 description: Create a report to help us improve
name: Bug report name: Bug report
title: "" title: "bug: "
type: Bug type: Bug
labels: ["needs triage"] labels: ["triage"]
projects: ["penpot/8"]
body: body:
- type: markdown - type: markdown

View File

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

View File

@ -6,6 +6,7 @@ on:
jobs: jobs:
build-and-push: build-and-push:
name: Build and push DevEnv Docker image name: Build and push DevEnv Docker image
environment: release-admins
runs-on: penpot-runner-02 runs-on: penpot-runner-02
steps: steps:
@ -38,13 +39,3 @@ jobs:
tags: ${{ env.DOCKER_IMAGE }}:latest tags: ${{ env.DOCKER_IMAGE }}:latest
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max 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: jobs:
release: release:
environment: release-admins
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs: outputs:
version: ${{ steps.vars.outputs.gh_ref }} 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 .repl
/*.jpg /*.jpg
/*.md /*.md
!CHANGES.md
!CONTRIBUTING.md
!README.md
!AGENTS.md
!CODE_OF_CONDUCT.md
!SECURITY.md
/*.png /*.png
/*.svg /*.svg
/*.sql /*.sql
@ -93,10 +87,6 @@
/.pnpm-store /.pnpm-store
/.vscode /.vscode
/.idea /.idea
*.iml
/.claude /.claude
/.playwright-mcp /.playwright-mcp
/.devenv/mcp/
/opencode.json
/.codex/
/tools/__pycache__ /tools/__pycache__

View File

@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow # 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. - 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. - 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. - 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 # 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) ## 2.16.0 (Unreleased)
### :boom: Breaking changes & Deprecations ### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights ### :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)) - 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) - 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)) - 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 ### :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)) - 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)) - 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)) - 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) - Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
## 2.15.1 ## 2.15.1
### :sparkles: New features & Enhancements ### :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)) - 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 ## 2.15.0
### :sparkles: New features & Enhancements ### :sparkles: New features & Enhancements

View File

@ -159,7 +159,7 @@ To save time on both sides, please avoid submitting PRs that:
### Good first issues ### 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 ## Commit Guidelines
@ -175,26 +175,26 @@ Commit messages must follow this format:
### Commit types ### Commit types
| Emoji | Description | | Emoji | Description |
| ---------------------- | -------------------------- | |-------|-------------|
| :bug: | Bug fix | | :bug: | Bug fix |
| :sparkles: | Improvement or enhancement | | :sparkles: | Improvement or enhancement |
| :tada: | New feature | | :tada: | New feature |
| :recycle: | Refactor | | :recycle: | Refactor |
| :lipstick: | Cosmetic changes | | :lipstick: | Cosmetic changes |
| :ambulance: | Critical bug fix | | :ambulance: | Critical bug fix |
| :books: | Documentation | | :books: | Documentation |
| :construction: | Work in progress | | :construction: | Work in progress |
| :boom: | Breaking change | | :boom: | Breaking change |
| :wrench: | Configuration update | | :wrench: | Configuration update |
| :zap: | Performance improvement | | :zap: | Performance improvement |
| :whale: | Docker-related change | | :whale: | Docker-related change |
| :paperclip: | Other non-relevant changes | | :paperclip: | Other non-relevant changes |
| :arrow_up: | Dependency update | | :arrow_up: | Dependency update |
| :arrow_down: | Dependency downgrade | | :arrow_down: | Dependency downgrade |
| :fire: | Removal of code or files | | :fire: | Removal of code or files |
| :globe_with_meridians: | Add or update translations | | :globe_with_meridians: | Add or update translations |
| :rocket: | Epic or highlight | | :rocket: | Epic or highlight |
### Rules ### Rules
@ -231,19 +231,6 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
./scripts/lint ./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. Ideally, run these as git pre-commit hooks.
[Husky](https://typicode.github.io/husky/#/) is a convenient option for [Husky](https://typicode.github.io/husky/#/) is a convenient option for
setting this up. 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: > 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 > (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 > (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 > knowledge, is covered under an appropriate open source license and I have
> the right under that license to submit that work with modifications, > 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 > 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 > license (unless I am permitted to submit under a different license), as
> indicated in the file; or > indicated in the file; or
> >
> (c) The contribution was provided directly to me by some other person who > (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 > (d) I understand and agree that this project and the contribution are public
> and that a record of the contribution (including all personal information > and that a record of the contribution (including all personal information
> I submit with it, including my sign-off) is maintained indefinitely and > I submit with it, including my sign-off) is maintained indefinitely and
> may be redistributed consistent with this project or the open source > may be redistributed consistent with this project or the open source
> license(s) involved. > license(s) involved.
### Signed-off-by ### Signed-off-by

View File

@ -5,7 +5,7 @@
<p align="center"> <p align="center">
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow"> <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>
<a href="https://community.penpot.app" rel="nofollow"> <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"> <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 We take the security of this project seriously. If you have discovered
a security vulnerability, please do **not** open a public issue. 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 Please report vulnerabilities via email to: **[support@penpot.app]**
) feature in the Penpot repository.
### What to include: ### What to include:

View File

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

View File

@ -67,8 +67,7 @@
;; Pretty Print specs ;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"} pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.44.4"} software.amazon.awssdk/s3 {:mvn/version "2.44.4"}}
software.amazon.awssdk/sts {:mvn/version "2.44.4"}}
:paths ["src" "resources" "target/classes"] :paths ["src" "resources" "target/classes"]
:aliases :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;"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr> <tr>
<td width="20" height="20" align="center" valign="middle" <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"> 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> </td>
</tr> </tr>
</table> </table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;"> <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> </span>
</div> </div>
</td> </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! 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: 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.rpc.commands.profile :as profile]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.cache :as cache]
[app.util.inet :as inet] [app.util.inet :as inet]
[app.util.json :as json] [app.util.json :as json]
[buddy.sign.jwk :as jwk] [buddy.sign.jwk :as jwk]
@ -695,24 +694,15 @@
(db/pgarray? roles) (db/pgarray? roles)
(assoc :roles (db/decode-pgarray roles #{})))) (assoc :roles (db/decode-pgarray roles #{}))))
;; A short TTL avoids paying the OIDC discovery + JWKS fetch on every ;; TODO: add cache layer for avoid build an discover each time
;; 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))))
(defn get-provider (defn get-provider
[cfg id] [cfg id]
(try (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 (catch Throwable cause
(l/err :hint "unable to configure custom SSO provider" (l/err :hint "unable to configure custom SSO provider"
:provider (str id) :provider (str id)

View File

@ -315,8 +315,8 @@
(defn get-file (defn get-file
"Get file, resolve all features and apply migrations. "Get file, resolve all features and apply migrations.
Useful when you have plan to apply massive or not surgical Usefull when you have plan to apply massive or not cirurgical
operations on file, because it removes the overhead of lazy fetching operations on file, because it removes the ovehead of lazy fetching
and decoding." and decoding."
[cfg file-id & {:as opts}] [cfg file-id & {:as opts}]
(db/run! cfg get-file* file-id opts)) (db/run! cfg get-file* file-id opts))
@ -843,12 +843,7 @@
l.vern, l.vern,
l.is_shared, l.is_shared,
l.version, l.version,
fls.synced_at, 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
FROM libs AS l FROM libs AS l
JOIN project AS p JOIN project AS p
ON p.id = l.project_id ON p.id = l.project_id
@ -860,8 +855,12 @@
(defn get-file-libraries (defn get-file-libraries
[conn file-id] [conn file-id]
(into [] (into []
(map decode-row-features) (comp
(db/exec! conn [sql:get-file-libraries file-id file-id file-id]))) ;; 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 (defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of "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-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-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] [:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string] [:nitrate-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string]
@ -300,7 +295,7 @@
(sm/explainer schema:config)) (sm/explainer schema:config))
(defn read-config (defn read-config
"Reads the configuration from environment variables and decodes all "Reads the configuration from enviroment variables and decodes all
known values." known values."
[& {:keys [prefix default] :or {prefix "penpot"}}] [& {:keys [prefix default] :or {prefix "penpot"}}]
(->> (read-env prefix) (->> (read-env prefix)

View File

@ -431,19 +431,14 @@
:id ::invite-to-team :id ::invite-to-team
:schema schema: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 (def ^:private schema:invite-to-org
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]
[:organization-name ::sm/text]
[:organization-initials [:maybe :string]]
[:organization-logo ::sm/uri]
[:user-name [:maybe ::sm/text]] [:user-name [:maybe ::sm/text]]
[:token ::sm/text] [:token ::sm/text]])
[:organization schema:organization-data]])
(def invite-to-org (def invite-to-org
"Org member invitation email." "Org member invitation email."
@ -451,21 +446,6 @@
:id ::invite-to-org :id ::invite-to-org
:schema schema: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 (def ^:private schema:join-team
[:map [:map
[:invited-by ::sm/text] [:invited-by ::sm/text]

View File

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

View File

@ -144,15 +144,6 @@
{::yres/status 404 {::yres/status 404
::yres/body (ex-data err)}) ::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 (defmethod handle-error :internal
[error request parent-cause] [error request parent-cause]
(binding [l/*context* (request->context request)] (binding [l/*context* (request->context request)]

View File

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

View File

@ -21,7 +21,7 @@
(defn- write! (defn- write!
[^OutputStream output ^bytes data] [^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) (.write output data)
(.flush output)) (.flush output))

View File

@ -7,7 +7,6 @@
(ns app.nitrate (ns app.nitrate
"Module that make calls to the external nitrate aplication" "Module that make calls to the external nitrate aplication"
(:require (:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.json :as json] [app.common.json :as json]
[app.common.logging :as l] [app.common.logging :as l]
@ -29,16 +28,14 @@
(defn- request-builder (defn- request-builder
[cfg method uri shared-key profile-id request-params] [cfg method uri shared-key profile-id request-params]
(fn [] (fn []
(http/req cfg (http/req cfg (cond-> {:method method
(cond-> {:method method :headers {"content-type" "application/json"
:headers {"content-type" "application/json" "accept" "application/json"
"accept" "application/json" "x-shared-key" shared-key
"x-shared-key" shared-key "x-profile-id" (str profile-id)}
"x-profile-id" (str profile-id)} :uri uri
:uri uri :version :http1.1}
:version :http1.1} (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
{:skip-ssrf-check? true})))
(defn- with-retries (defn- with-retries
[handler max-retries] [handler max-retries]
@ -62,29 +59,14 @@
(fn [] (fn []
(let [response (handler) (let [response (handler)
status (:status response)] 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 (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) (>= status 400)
;; For client error status codes (4xx), fail immediately without validation ;; For error status codes (4xx, 5xx), fail immediately without validation
(do (do
(when (not= status 404) ;; Don't need to log 404 (when (not= status 404) ;; Don't need to log 404
(l/error :hint "nitrate request failed with error status" (l/error :hint "nitrate request failed with error status"
@ -189,7 +171,6 @@
"day" "day"
"week" "week"
"year"]] "year"]]
[:manual :boolean]
[:quantity :int] [:quantity :int]
[:description [:maybe ::sm/text]] [:description [:maybe ::sm/text]]
[:created-at schema:timestamp] [:created-at schema:timestamp]
@ -275,42 +256,6 @@
[:vector schema:org-summary] [:vector schema:org-summary]
params))) 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 (defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}] [cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri) (let [baseuri (cf/get :nitrate-backend-uri)
@ -322,7 +267,7 @@
organization-id organization-id
"/add-team") "/add-team")
cto/schema:team-with-organization params) 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))] (str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team (cond-> team
custom-photo custom-photo
@ -391,24 +336,6 @@
profile-id) profile-id)
schema:subscription params))) 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 (defn- get-connectivity-api
[cfg params] [cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
@ -421,31 +348,6 @@
[:map [:map
[:cancel-at [:maybe schema:timestamp]]]) [: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 (defn- redeem-activation-code-api
[cfg params] [cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)] (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-membership-by-team (partial get-org-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg) :get-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-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) :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-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-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) :delete-team (partial delete-team-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg) :remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg) :get-subscription (partial get-subscription-api cfg)
:get-subscription-warning (partial get-subscription-warning-api cfg)
:connectivity (partial get-connectivity-api cfg) :connectivity (partial get-connectivity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)})) :redeem-activation-code (partial redeem-activation-code-api cfg)}))
@ -489,27 +386,21 @@
(defn add-nitrate-licence-to-profile (defn add-nitrate-licence-to-profile
"Enriches a profile map with subscription information from Nitrate. "Enriches a profile map with subscription information from Nitrate.
Adds a :subscription field containing the user's license details. Adds a :subscription field containing the user's license details.
Returns the original profile unchanged if the request fails for a reason Returns the original profile unchanged if the request fails."
other than Nitrate being unreachable. When Nitrate is unreachable the
`:nitrate-unavailable` exception propagates so the request is rejected."
[cfg profile] [cfg profile]
(try (try
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
(assoc profile :subscription subscription)) (assoc profile :subscription subscription))
(catch Throwable cause (catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type)) (l/error :hint "failed to get nitrate licence"
(throw cause) :profile-id (:id profile)
(do :cause cause)
(l/error :hint "failed to get nitrate licence" profile)))
:profile-id (:id profile)
:cause cause)
profile)))))
(defn add-org-info-to-team (defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate. "Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. 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. 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."
[cfg team params] [cfg team params]
(try (try
(let [params (assoc (or params {}) :team-id (:id team)) (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))))) (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team)) team))
(catch Throwable cause (catch Throwable cause
(if (= :nitrate-unavailable (-> cause ex-data :type)) (l/error :hint "failed to get team organization info"
(throw cause) :team-id (:id team)
(do :cause cause)
(l/error :hint "failed to get team organization info" team)))
:team-id (:id team)
:cause cause)
team)))))
(defn set-team-organization (defn set-team-organization
"Associates a team with an organization in Nitrate. "Associates a team with an organization in Nitrate.
@ -546,3 +434,7 @@
:context {:team-id (:id team) :context {:team-id (:id team)
:organization-id (:organization-id params)})) :organization-id (:organization-id params)}))
team)) team))

View File

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

View File

@ -409,7 +409,10 @@
[cfg {:keys [::rpc/profile-id file-id] :as params}] [cfg {:keys [::rpc/profile-id file-id] :as params}]
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (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) (when-not (db/read-only? conn)
(let [media (create-file-thumbnail cfg params)] (let [media (create-file-thumbnail cfg params)]
{:uri (files/resolve-public-uri (:id media)) {:uri (files/resolve-public-uri (:id media))

View File

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

View File

@ -12,8 +12,6 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -59,22 +57,6 @@
[cfg _params] [cfg _params]
(nitrate/call cfg :connectivity {})) (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 (def ^:private schema:redeem-activation-code-params
[:map {:title "RedeemActivationCodeParams"} [:map {:title "RedeemActivationCodeParams"}
[:activation-code ::sm/text]]) [:activation-code ::sm/text]])
@ -128,47 +110,12 @@
AND t.id = ANY(?) AND t.id = ANY(?)
AND t.deleted_at IS NULL") AND t.deleted_at IS NULL")
(def ^:private sql:get-teams-files-counts (def sql:get-team-files-count
"SELECT p.team_id, count(*) AS total "SELECT count(*) AS total
FROM file AS f FROM file AS f
JOIN project AS p ON (p.id = f.project_id) JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ANY(?) WHERE p.team_id = ?
AND f.deleted_at IS NULL 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)}))
(def ^:private schema:leave-org (def ^:private schema:leave-org
[:map [:map
@ -183,18 +130,6 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:reassign-to {:optional true} ::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 (defn- get-organization-teams-for-user
[{:keys [::db/conn] :as cfg} org-summary profile-id] [{:keys [::db/conn] :as cfg} org-summary profile-id]
@ -284,14 +219,16 @@
:code :not-valid-teams)))) :code :not-valid-teams))))
(defn leave-org (defn leave-org
[{:keys [::db/conn] :as cfg} [{:keys [::db/conn] :as cfg} {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}]
{: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) "] ")
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
{:keys [deletable-team-ids default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id])
keep-default-team? :total)
detach-from-org-team-ids]} (build-leave-org-plan cfg default-team-id teams-to-delete keep-default-team-requested?)] delete-default-team? (= default-team-files-count 0)]
;; assert that the received teams are valid, checking the different constraints ;; assert that the received teams are valid, checking the different constraints
(when-not skip-validation (when-not skip-validation
@ -299,27 +236,20 @@
(assert-membership cfg profile-id id) (assert-membership cfg profile-id id)
;; delete only eligible teams (non-protected and without files) ;; delete the teams-to-delete
(doseq [id deletable-team-ids] (doseq [id teams-to-delete]
(teams/delete-team cfg {:profile-id profile-id (teams/delete-team cfg {:profile-id profile-id :team-id id}))
:team-id id}))
;; leave the teams-to-leave ;; leave the teams-to-leave
(doseq [{:keys [id reassign-to]} 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})) (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. ;; Delete default-team-id if empty; otherwise keep it and prefix the name.
(when default-team-id (if delete-default-team?
(if keep-default-team? (do
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]) (db/update! conn :team {:is-default false} {:id default-team-id})
(teams/delete-team cfg {:profile-id profile-id (teams/delete-team cfg {:profile-id profile-id :team-id default-team-id}))
:team-id default-team-id}))) (db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix 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}))
;; Api call to nitrate ;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id}) (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))) (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 (def ^:private schema:remove-team-from-org
[:map [:map
[:team-id ::sm/uuid] [:team-id ::sm/uuid]
@ -369,20 +280,6 @@
(assert-is-owner cfg profile-id team-id) (assert-is-owner cfg profile-id team-id)
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
;; Check moveTeams permission on the source organization
(when (contains? cf/flags :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 ;; Api call to nitrate
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id}) (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") (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
nil) 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 (def ^:private schema:add-team-to-organization
[:map [:map
@ -447,173 +305,15 @@
(assert-not-default-team cfg team-id) (assert-not-default-team cfg team-id)
(assert-membership cfg profile-id organization-id) (assert-membership cfg profile-id organization-id)
(when (contains? cf/flags :nitrate) (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id}) ;; Add teammates to the org if needed
source-org-id (get-in team-with-org [:organization :id]) (doseq [{member-id :profile-id} team-members
source-org-perms (when source-org-id :when (not= member-id profile-id)]
(nitrate/call cfg :get-org-permissions (teams/initialize-user-in-nitrate-org cfg member-id organization-id)))
{: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"))
;; Team already belongs to an organization: check move-teams on source org. ;; Api call to nitrate
(when (some? source-org-id) (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
(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])))))
;; Notify connected users
(notifications/notify-team-change cfg team "dashboard.team-belong-org"))
nil) 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) (nitrate/add-nitrate-licence-to-profile cfg profile)
profile)) profile))
(catch Throwable cause (catch Throwable _
(if (= :not-found (-> cause ex-data :type)) {:id uuid/zero :fullname "Anonymous User"})))
{:id uuid/zero :fullname "Anonymous User"}
(throw cause)))))
(defn get-profile (defn get-profile
"Get profile by id. Throws not-found exception if no profile found." "Get profile by id. Throws not-found exception if no profile found."
@ -485,16 +483,8 @@
{:deleted-at deleted-at} {:deleted-at deleted-at}
{:id profile-id}) {:id profile-id})
;; Delete owned organizations on the fly (no grace period). ;; Api call to nitrate
;; Nitrate iterates the user's owned orgs and, per org, calls (nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
;; 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}))
;; Schedule cascade deletion to a worker ;; Schedule cascade deletion to a worker
(wrk/submit! {::db/conn conn (wrk/submit! {::db/conn conn
@ -503,6 +493,7 @@
:deleted-at deleted-at :deleted-at deleted-at
:id profile-id}}) :id profile-id}})
(-> (rph/wrap nil) (-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg))))) (rph/with-transform (session/delete-fn cfg)))))
@ -529,32 +520,6 @@
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])] (let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
{:editors editors})) {: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 ;; --- HELPERS
(def sql:owned-teams (def sql:owned-teams

View File

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

View File

@ -14,7 +14,6 @@
[app.common.logging :as l] [app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.types.team :as types.team] [app.common.types.team :as types.team]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
@ -113,19 +112,8 @@
(let [notifications (dm/get-in member [:props :notifications])] (let [notifications (dm/get-in member [:props :notifications])]
(not= :none (:email-invites 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 (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) (assert (db/connection-map? cfg)
"expected cfg with valid connection") "expected cfg with valid connection")
@ -142,13 +130,6 @@
:code :email-domain-is-not-allowed :code :email-domain-is-not-allowed
:hint "email domain is in the blacklist")) :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 ;; When we have email verification disabled and invitation user is
;; already present in the database, we proceed to add it to the ;; already present in the database, we proceed to add it to the
;; team as-is, without email roundtrip. ;; team as-is, without email roundtrip.
@ -237,26 +218,32 @@
:to email :to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
:user-name (:fullname member) :user-name (:fullname member)
:organization organization :organization-name (:name organization)
:organization-logo (:logo organization)
:organization-initials (:initials organization)
:token itoken :token itoken
:extra-data ptoken})) :extra-data ptoken}))
(eml/send! {::eml/conn conn (let [team (if (contains? cf/flags :nitrate)
::eml/factory eml/invite-to-team (nitrate/add-org-info-to-team cfg team {})
:public-uri (cf/get :public-uri) team)]
:to email (eml/send! {::eml/conn conn
:invited-by (:fullname profile) ::eml/factory eml/invite-to-team
:team (:name team) :public-uri (cf/get :public-uri)
:organization (dm/get-in team [:organization :name]) :to email
:token itoken :invited-by (:fullname profile)
:extra-data ptoken}))) :team (:name team)
:organization (:organization-name team)
:token itoken
:extra-data ptoken}))))
itoken))))) itoken)))))
(defn create-org-invitation (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)] (let [profile (db/get-by-id cfg :profile profile-id)]
(create-invitation cfg (create-invitation cfg
(assoc params (assoc params
:organization {:id id :name name :initials initials :logo logo}
:profile profile :profile profile
:role :editor)))) :role :editor))))
@ -322,18 +309,7 @@
- emails (set) + role (single role for all emails) - emails (set) + role (single role for all emails)
- invitations (vector of {:email :role} maps)" - invitations (vector of {:email :role} maps)"
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}] [{: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 (let [;; Normalize input to a consistent format: [{:email :role}]
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}]
invitation-data (cond invitation-data (cond
;; Case 1: emails + single role (create invitations style) ;; Case 1: emails + single role (create invitations style)
(and emails role) (and emails role)

View File

@ -19,7 +19,7 @@
of the object. This function can be applied to the object returned by the 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 `get-object` but also to the RPC return value (in case you don't provide
the return value calculated key under `::key` metadata prop. 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 when the target object is not retrieved on the RPC (typical on retrieving
dependent objects). dependent objects).
" "

View File

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

View File

@ -12,13 +12,11 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [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.profile :refer [schema:profile, schema:basic-profile]]
[app.common.types.team :refer [schema:team]] [app.common.types.team :refer [schema:team]]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.email :as eml]
[app.loggers.audit :as audit]
[app.media :as media] [app.media :as media]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -31,8 +29,7 @@
[app.rpc.notifications :as notifications] [app.rpc.notifications :as notifications]
[app.storage :as sto] [app.storage :as sto]
[app.util.services :as sv] [app.util.services :as sv]
[app.worker :as wrk] [app.worker :as wrk]))
[cuerdas.core :as str]))
(defn- profile-to-map [profile] (defn- profile-to-map [profile]
@ -51,8 +48,7 @@
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)] (let [profile (profile/get-profile cfg profile-id)]
(-> (profile-to-map profile) (-> (profile-to-map profile)
(assoc :theme (:theme profile)) (assoc :theme (:theme profile)))))
(assoc :lang (:lang profile)))))
;; ---- API: get-teams ;; ---- API: get-teams
@ -300,61 +296,46 @@ RETURNING id, deleted_at;")
nil) nil)
(defn manage-deleted-organization-teams (defn manage-deleted-organization-teams
"For a deleted organization, preserve org teams unchanged and only prefix or "For a list of teams, rename those with files and delete those without, then notify users."
delete member Your Penpot teams depending on whether they still contain files." [cfg {:keys [teams organization-name]}]
[cfg {:keys [organization-id organization-name teams]}] (let [teams (->> teams (filter uuid?) distinct (into []))]
(let [all-team-ids (->> teams (when (seq 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)
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")] (let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
(db/tx-run! (db/tx-run!
cfg cfg
(fn [{:keys [::db/conn] :as cfg}] (fn [{:keys [::db/conn] :as cfg}]
(let [teams-with-files (if (seq your-penpot-team-ids) (let [teams-array (db/create-array conn "uuid" teams)
(->> (db/exec! conn [sql:get-teams-files-counts teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array])
(db/create-array conn "uuid" your-penpot-team-ids)]) (filter (fn [{:keys [total]}] (pos? total)))
(filter (fn [{:keys [total]}] (pos? total))) (map :team-id)
(map :team-id) (into #{}))
(into #{})) teams-to-keep (->> teams (filter teams-with-files) (into []))
#{}) teams-to-delete (->> teams (remove teams-with-files) (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 []))]
;; Org teams move to the fallback org unchanged. Only imported ;; Rename teams that have files in one go
;; Your Penpot teams keep the org prefix when they still have files. (when (seq teams-to-keep)
(when (seq teams-to-prefix)
(db/exec! conn [sql:prefix-teams-name-and-unset-default (db/exec! conn [sql:prefix-teams-name-and-unset-default
org-prefix 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) (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))))))) nil)))))))
(sv/defmethod ::notify-organization-deletion (sv/defmethod ::notify-organization-deletion
"For a deleted organization, preserve org teams and only prefix or delete "For a list of teams, rename them with the name of the deleted org, and notify
imported Your Penpot teams before notifying connected users." of the deletion to the connected users"
{::doc/added "2.15" {::doc/added "2.15"
::sm/params schema:notify-organization-deletion ::sm/params schema:notify-organization-deletion
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [organization-id]}] [cfg {:keys [organization-id]}]
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
teams (:teams org-summary)] teams (->> (:teams org-summary)
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary) (map :id))]
:organization-id (:id org-summary) (manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)})
:teams teams})
nil)) nil))
;; ---- API: notify-user-organizations-deletion ;; ---- API: notify-user-organizations-deletion
@ -364,18 +345,15 @@ RETURNING id, deleted_at;")
[:profile-id ::sm/uuid]]) [:profile-id ::sm/uuid]])
(sv/defmethod ::notify-user-organizations-deletion (sv/defmethod ::notify-user-organizations-deletion
"For a given user, find all owned organizations and apply the deleted-org "For a given user, find all owned organizations and rename or delete their teams."
transfer rules to their imported Your Penpot teams."
{::doc/added "2.18" {::doc/added "2.18"
::sm/params schema:notify-user-organizations-deletion} ::sm/params schema:notify-user-organizations-deletion}
[cfg {:keys [profile-id]}] [cfg {:keys [profile-id]}]
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] (let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
(doseq [org owned-orgs] (doseq [org owned-orgs]
(let [organization-name (:name org) (let [organization-name (:name org)
teams (:teams org)] teams (map :id (:teams org))]
(manage-deleted-organization-teams cfg {:organization-name organization-name (manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name}))))
:organization-id (:id org)
:teams teams}))))
nil) nil)
@ -476,7 +454,10 @@ RETURNING id, deleted_at;")
{::doc/added "2.15" {::doc/added "2.15"
::sm/params [:map ::sm/params [:map
[:email ::sm/email] [:email ::sm/email]
[:organization schema:organization-with-avatar]]} [:id ::sm/uuid]
[:name ::sm/text]
[:initials [:maybe :string]]
[:logo ::sm/uri]]}
[cfg params] [cfg params]
(db/tx-run! cfg ti/create-org-invitation params) (db/tx-run! cfg ti/create-org-invitation params)
nil) nil)
@ -491,7 +472,6 @@ RETURNING id, deleted_at;")
ti.email_to AS email, ti.email_to AS email,
ti.created_at AS sent_at, ti.created_at AS sent_at,
p.fullname AS name, p.fullname AS name,
p.id AS profile_id,
p.photo_id p.photo_id
FROM team_invitation AS ti FROM team_invitation AS ti
LEFT JOIN profile AS p LEFT JOIN profile AS p
@ -513,7 +493,6 @@ LEFT JOIN profile AS p
[:email ::sm/email] [:email ::sm/email]
[:sent-at ::sm/inst] [:sent-at ::sm/inst]
[:name {:optional true} [:maybe ::sm/text]] [:name {:optional true} [:maybe ::sm/text]]
[:profile-id {:optional true} [:maybe ::sm/uuid]]
[:photo-url {:optional true} ::sm/uri]]]) [:photo-url {:optional true} ::sm/uri]]])
(sv/defmethod ::get-org-invitations (sv/defmethod ::get-org-invitations
@ -565,33 +544,6 @@ LEFT JOIN profile AS p
nil)) 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 ;; API: remove-from-org
@ -651,8 +603,7 @@ LEFT JOIN profile AS p
[:map [:map
[:teams-to-delete ::sm/int] [:teams-to-delete ::sm/int]
[:teams-to-transfer ::sm/int] [:teams-to-transfer ::sm/int]
[:teams-to-exit ::sm/int] [:teams-to-exit ::sm/int]])
[:teams-to-detach ::sm/int]])
(sv/defmethod ::get-remove-from-org-summary (sv/defmethod ::get-remove-from-org-summary
"Get a summary of the teams that would be deleted, transferred, or exited "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 (when-not valid-default-team
(ex/raise :type :validation (ex/raise :type :validation
:code :not-valid-teams)) :code :not-valid-teams))
(cnit/get-leave-org-summary cfg {:teams-to-delete (count valid-teams-to-delete-ids)
default-team-id :teams-to-transfer (count valid-teams-to-transfer)
valid-teams-to-delete-ids :teams-to-exit (count valid-teams-to-exit)}))
(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))

View File

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

View File

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

View File

@ -12,6 +12,7 @@
[app.common.time :as ct] [app.common.time :as ct]
[promesa.exec :as px]) [promesa.exec :as px])
(:import (:import
com.github.benmanes.caffeine.cache.AsyncCache
com.github.benmanes.caffeine.cache.Cache com.github.benmanes.caffeine.cache.Cache
com.github.benmanes.caffeine.cache.Caffeine com.github.benmanes.caffeine.cache.Caffeine
com.github.benmanes.caffeine.cache.RemovalListener com.github.benmanes.caffeine.cache.RemovalListener
@ -46,18 +47,15 @@
:miss-rate (.missRate stats)})) :miss-rate (.missRate stats)}))
(defn create (defn create
"Build an in-memory cache. Loads run synchronously on the calling [& {:keys [executor on-remove max-size keepalive]}]
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]}]
(let [cache (as-> (Caffeine/newBuilder) builder (let [cache (as-> (Caffeine/newBuilder) builder
(if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder) (if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder)
(if executor (.executor builder ^Executor (px/resolve-executor executor)) builder) (if executor (.executor builder ^Executor (px/resolve-executor executor)) builder)
(if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) 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) (if (int? max-size) (.maximumSize builder (long max-size)) builder)
(.recordStats builder) (.recordStats builder)
(.build builder))] (.buildAsync builder))
cache (.synchronous ^AsyncCache cache)]
(reify (reify
ICache ICache
(get [_ k] (get [_ k]
@ -71,7 +69,7 @@
(invalidate! [_] (invalidate! [_]
(.invalidateAll ^Cache cache)) (.invalidateAll ^Cache cache))
(invalidate! [_ k] (invalidate! [_ k]
(.invalidate ^Cache cache ^Object k)) (.invalidateAll ^Cache cache ^Object k))
ICacheStats ICacheStats
(stats [_] (stats [_]

View File

@ -17,7 +17,6 @@
[app.rpc.commands.access-token] [app.rpc.commands.access-token]
[app.tokens :as tokens] [app.tokens :as tokens]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[clojure.string :as str]
[clojure.test :as t] [clojure.test :as t]
[mockery.core :refer [with-mocks]] [mockery.core :refer [with-mocks]]
[yetti.request :as yreq] [yetti.request :as yreq]
@ -113,74 +112,6 @@
(t/is (= #{} (:app.http.access-token/perms response))) (t/is (= #{} (:app.http.access-token/perms response)))
(t/is (= (:id profile) (:app.http.access-token/profile-id 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 (t/deftest session-authz
(let [cfg th/*system* (let [cfg th/*system*
manager (session/inmemory-manager) 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 (nil? (sto/get-object storage (:media-id row1))))
(t/is (some? (sto/get-object storage (:media-id row2)))) (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})] (let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})]
(t/is (nil? row)))))) (t/is (nil? row))))))
@ -254,32 +254,6 @@
(t/is (some? (sto/get-object storage (:media-id row2))))))) (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 (t/deftest error-on-direct-storage-obj-deletion
(let [storage (::sto/storage th/*system*) (let [storage (::sto/storage th/*system*)
profile (th/create-profile* 1) profile (th/create-profile* 1)

View File

@ -186,10 +186,8 @@
expected-start (str "[" (d/sanitize-string organization-name) "] ") expected-start (str "[" (d/sanitize-string organization-name) "] ")
org-summary {:id organization-id org-summary {:id organization-id
:name organization-name :name organization-name
:teams [{:id (:id team-with-files) :teams [{:id (:id team-with-files)}
:is-your-penpot true} {:id (:id empty-team)}]}
{:id (:id empty-team)
:is-your-penpot true}]}
calls (atom []) calls (atom [])
submitted (atom []) submitted (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params] out (with-redefs [nitrate/call (fn [_cfg method params]
@ -224,7 +222,6 @@
(let [{:keys [topic message]} (first @calls)] (let [{:keys [topic message]} (first @calls)]
(t/is (= uuid/zero topic)) (t/is (= uuid/zero topic))
(t/is (= :organization-deleted (:type message))) (t/is (= :organization-deleted (:type message)))
(t/is (= organization-id (:organization-id message)))
(t/is (= organization-name (:organization-name message))) (t/is (= organization-name (:organization-name message)))
(t/is (= #{(:id team-with-files) (:id empty-team)} (t/is (= #{(:id team-with-files) (:id empty-team)}
(set (:teams message)))) (set (:teams message))))
@ -257,16 +254,12 @@
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ") org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
owned-orgs [{:id org-1-id owned-orgs [{:id org-1-id
:name org-1-name :name org-1-name
:teams [{:id (:id org-1-team-files) :teams [{:id (:id org-1-team-files)}
:is-your-penpot true} {:id (:id org-1-team-empty)}]}
{:id (:id org-1-team-empty)
:is-your-penpot true}]}
{:id org-2-id {:id org-2-id
:name org-2-name :name org-2-name
:teams [{:id (:id org-2-team-files) :teams [{:id (:id org-2-team-files)}
:is-your-penpot true} {:id (:id org-2-team-empty)}]}]
{:id (:id org-2-team-empty)
:is-your-penpot true}]}]
calls (atom []) calls (atom [])
submitted (atom []) submitted (atom [])
out (with-redefs [nitrate/call (fn [_cfg method params] out (with-redefs [nitrate/call (fn [_cfg method params]
@ -320,8 +313,6 @@
m2 (org-msg org-2-name)] m2 (org-msg org-2-name)]
(t/is (some? m1)) (t/is (some? m1))
(t/is (some? m2)) (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)} (t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
(set (:teams m1)))) (set (:teams m1))))
(t/is (= #{(:id org-1-team-empty)} (t/is (= #{(:id org-1-team-empty)}
@ -570,263 +561,6 @@
(t/is (= (:id outside-team) (:team-id (first remaining-target)))) (t/is (= (:id outside-team) (:team-id (first remaining-target))))
(t/is (= 1 (count remaining-other)))))) (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 ;; Tests: remove-from-org
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -1074,8 +808,7 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 0 :teams-to-exit 0}
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-delete (t/deftest get-remove-from-org-summary-with-teams-to-delete
@ -1101,8 +834,7 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 1 (t/is (= {:teams-to-delete 1
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 0 :teams-to-exit 0}
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-transfer (t/deftest get-remove-from-org-summary-with-teams-to-transfer
@ -1132,8 +864,7 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 1 :teams-to-transfer 1
:teams-to-exit 0 :teams-to-exit 0}
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-with-teams-to-exit (t/deftest get-remove-from-org-summary-with-teams-to-exit
@ -1162,8 +893,7 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= {:teams-to-delete 0 (t/is (= {:teams-to-delete 0
:teams-to-transfer 0 :teams-to-transfer 0
:teams-to-exit 1 :teams-to-exit 1}
:teams-to-detach 0}
(:result out))))) (:result out)))))
(t/deftest get-remove-from-org-summary-does-not-mutate (t/deftest get-remove-from-org-summary-does-not-mutate

View File

@ -19,8 +19,7 @@
[backend-tests.storage-test :refer [configure-storage-backend]] [backend-tests.storage-test :refer [configure-storage-backend]]
[buddy.core.bytes :as b] [buddy.core.bytes :as b]
[clojure.test :as t] [clojure.test :as t]
[datoteka.fs :as fs] [datoteka.fs :as fs]))
[datoteka.io :as io]))
(t/use-fixtures :once th/state-init) (t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
@ -40,23 +39,6 @@
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(:result 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 (t/deftest duplicate-file
(let [storage (-> (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend)) (configure-storage-backend))

View File

@ -7,7 +7,6 @@
(ns backend-tests.rpc-nitrate-test (ns backend-tests.rpc-nitrate-test
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as-alias db] [app.db :as-alias db]
[app.nitrate :as nitrate] [app.nitrate :as nitrate]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
@ -45,13 +44,6 @@
:organization-id (:id org-summary)} :organization-id (:id org-summary)}
nil))) nil)))
(defn- nitrate-org-summary-only-mock
[org-summary]
(fn [_cfg method _params]
(case method
:get-org-summary org-summary
nil)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests ;; Tests
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -287,64 +279,6 @@
(let [team (th/db-get :team {:id (:id team1)})] (let [team (th/db-get :team {:id (:id team1)})]
(t/is (nil? (:deleted-at team)))))))) (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 (t/deftest leave-org-error-org-owner-cannot-leave
(let [profile-owner (th/create-profile* 1 {:is-active true}) (let [profile-owner (th/create-profile* 1 {:is-active true})
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)}) 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 (= :validation (th/ex-type (:error out))))
(t/is (= :not-valid-teams (th/ex-code (: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 (t/deftest leave-org-error-reassign-on-non-owned-team
(let [profile-owner (th/create-profile* 1 {:is-active true}) (let [profile-owner (th/create-profile* 1 {:is-active true})
profile-user (th/create-profile* 2 {: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-data storage object))))
(t/is (= "content" (slurp (sto/get-object-path 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 (t/deftest put-and-retrieve-expired-object
(let [storage (-> (:app.storage/storage th/*system*) (let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend)) (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'\"", "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", "build:test": "clojure -M:dev:shadow-cljs compile test",
"test:js": "pnpm run build:test && node target/tests/test.js", "test:js": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:jvm": "clojure -M:dev:test" "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))) (conj opacity)))
(defn hex->hsl [hex] (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 (defn hex->hsla
[data opacity] [data opacity]

View File

@ -17,7 +17,7 @@
(defmacro select-keys (defmacro select-keys
"A macro version of `select-keys`. Useful when keys vector is known "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 It is not 100% equivalent, this macro does not removes not existing
keys in contrast to clojure.core/select-keys" keys in contrast to clojure.core/select-keys"

View File

@ -1194,7 +1194,7 @@
;; frames. Return the ids of the frames affected ;; frames. Return the ids of the frames affected
(defn- parents-frames (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] [id objects]
(->> (cfh/get-parents-with-self objects id) (->> (cfh/get-parents-with-self objects id)
(filter cfh/frame-shape?))) (filter cfh/frame-shape?)))

View File

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

View File

@ -13,7 +13,7 @@
(defn- generate-index (defn- generate-index
"An optimized algorithm for calculate parents index that walk from top "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." create an index for the whole objects or subpart of the tree."
[index objects shape-id parents] [index objects shape-id parents]
(let [shape (get objects shape-id) (let [shape (get objects shape-id)

View File

@ -34,7 +34,7 @@
[app.common.types.shape.shadow :as ctss] [app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctst] [app.common.types.shape.text :as ctst]
[app.common.types.text :as types.text] [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] [app.common.uuid :as uuid]
[clojure.set :as set] [clojure.set :as set]
[cuerdas.core :as str])) [cuerdas.core :as str]))
@ -1599,7 +1599,7 @@
(defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids" (defmethod migrate-data "0014-fix-tokens-lib-duplicate-ids"
[data _] [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" (defmethod migrate-data "0014-clear-components-nil-objects"
[data _] [data _]
@ -1833,47 +1833,6 @@
(cfcp/fix-missing-swap-slots libraries) (cfcp/fix-missing-swap-slots libraries)
(cfcp/sync-component-id-with-ref-shape 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 (def available-migrations
(into (d/ordered-set) (into (d/ordered-set)
["legacy-2" ["legacy-2"
@ -1953,6 +1912,4 @@
"0019-fix-missing-swap-slots" "0019-fix-missing-swap-slots"
"0020-sync-component-id-with-near-main" "0020-sync-component-id-with-near-main"
"0021-fix-shape-svg-attrs" "0021-fix-shape-svg-attrs"
"0022-normalize-component-root-and-resync" "0022-normalize-component-root-and-resync"]))
"0023-repair-token-themes-with-inexistent-sets"
"0024b-fix-stroke-cap-placement"]))

View File

@ -543,7 +543,7 @@
(update :svg-attrs dissoc :fill) (update :svg-attrs dissoc :fill)
(assoc-in [:fills 0 :fill-color] (clr/parse color-style))) (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)) (and (or (clr/color-string? color-attr) (clr/color-string? color-style))
(dm/get-in shape [:svg-attrs :fillOpacity])) (dm/get-in shape [:svg-attrs :fillOpacity]))
(-> (update :svg-attrs dissoc :fillOpacity) (-> (update :svg-attrs dissoc :fillOpacity)
@ -609,13 +609,17 @@
(and (some? color) (some? width)) (and (some? color) (some? width))
(assoc-in [:strokes 0 :stroke-width] 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))) (or (= linecap :round) (= linecap :square)))
(assoc-in [:strokes 0 :stroke-cap-start] linecap)
(and (some? color) (some? linecap) (cfh/path-shape? shape) (assoc :stroke-cap-start linecap
(or (= linecap :round) (= linecap :square))) :stroke-cap-end linecap
(assoc-in [:strokes 0 :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] (defn setup-opacity [shape]
(cond-> shape (cond-> shape

View File

@ -148,9 +148,11 @@
(not (ctob/token-name-path-exists? % tokens-tree)))]]) (not (ctob/token-name-path-exists? % tokens-tree)))]])
(defn make-node-token-name-schema (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. "Dynamically generates a schema to check a token node name, adding translated error messages
This runs same checks as make-token-name-schema, but for all tokens that will be renamed by this change, and two additional validations:
if the group already contains tokens." - 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] [active-tokens tokens-tree node]
[:and [:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] [: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 (defn make-token-theme-schema
[tokens-lib group name theme-id] [tokens-lib group name theme-id]
[:and (sm/merge
(sm/merge ctob/schema:token-theme-attrs
ctob/schema:token-theme-attrs [:map
[:map [:group (make-token-theme-group-schema tokens-lib name theme-id)] ;; TODO how to keep error-fn from here?
[: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)]
[:name (make-token-theme-name-schema tokens-lib group theme-id)] [:description {:optional true} schema:token-theme-description]]))
[: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)))]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS

View File

@ -591,7 +591,7 @@
-it should be a main component -it should be a main component
-its parent should be a variant-container -its parent should be a variant-container
-its variant-name is derived from the properties -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] [shape file page]
(let [parent (ctst/get-shape page (:parent-id shape)) (let [parent (ctst/get-shape page (:parent-id shape))
@ -707,7 +707,7 @@
(if (#{:main-top :main-nested :main-any} context) (if (#{:main-top :main-nested :main-any} context)
(report-error :not-component-not-allowed (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) shape file page)
(check-shape-not-component shape file page libraries))))))))) (check-shape-not-component shape file page libraries)))))))))

View File

@ -11,7 +11,7 @@
[app.common.types.variant :as ctv])) [app.common.types.variant :as ctv]))
(defn find-variant-components (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] ([data variant-id]
(let [page-id (->> data (let [page-id (->> data
:components :components

View File

@ -72,11 +72,7 @@
:backend-worker :backend-worker
;; Only for development ;; Only for development
:component-thumbnails :component-thumbnails
;; Enables CORS support for the RPC API. Requires an explicit ;; enables the default cors configuration that allows all domains (currently this configuration is only used for development).
;; 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.
:cors :cors
;; Enables the templates dialog on Penpot dashboard. ;; Enables the templates dialog on Penpot dashboard.
:dashboard-templates-section :dashboard-templates-section
@ -169,7 +165,6 @@
:mcp :mcp
:background-blur :background-blur
:available-viewer-wasm
:stroke-path}) :stroke-path})
(def all-flags (def all-flags

View File

@ -34,7 +34,7 @@
;; modif-tree)))) ;; modif-tree))))
(defn- set-children-modifiers (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] [modif-tree children objects bounds parent transformed-parent-bounds ignore-constraints]
(let [modifiers (dm/get-in modif-tree [(:id parent) :modifiers])] (let [modifiers (dm/get-in modif-tree [(:id parent) :modifiers])]
;; Move modifiers don't need to calculate constraints ;; Move modifiers don't need to calculate constraints

View File

@ -11,8 +11,7 @@
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.path :as path] [app.common.types.path :as path]))
[app.common.types.stroke :as cts]))
(defn shape-stroke-margin (defn shape-stroke-margin
[shape stroke-width] [shape stroke-width]
@ -89,23 +88,14 @@
([shape] ([shape]
(get-shape-filter-bounds shape false)) (get-shape-filter-bounds shape false))
([shape ignore-shadow-margin?] ([shape ignore-shadow-margin?]
(cond (if (or (and (cfh/svg-raw-shape? shape)
;; SVG raw elements (non-root) don't have proper rotated points; use selrect (not= :svg (dm/get-in shape [:content :tag])))
(and (cfh/svg-raw-shape? shape) ;; If no shadows or blur, we return the selrect as is
(not= :svg (dm/get-in shape [:content :tag]))) (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) (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) (let [filters (shape->filters shape)
blur-value (case (-> shape :blur :type) blur-value (case (-> shape :blur :type)
:layer-blur (or (-> shape :blur :value) 0) :layer-blur (or (-> shape :blur :value) 0)
@ -115,19 +105,6 @@
(grc/points->rect))] (grc/points->rect))]
(get-rect-filter-bounds srect filters blur-value ignore-shadow-margin?))))) (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 (defn calculate-padding
([shape] ([shape]
(calculate-padding shape false false)) (calculate-padding shape false false))
@ -150,11 +127,6 @@
0 0
(shape-stroke-margin shape stroke-width)) (shape-stroke-margin shape stroke-width))
stroke-cap-margin
(if ignore-margin?
0
(stroke-cap-marker-margin strokes open-path?))
shadow-width shadow-width
(->> (:shadow shape) (->> (:shadow shape)
(remove :hidden) (remove :hidden)
@ -177,8 +149,8 @@
shadow-width shadow-width
(if ignore-shadow-margin? 0 shadow-width)] (if ignore-shadow-margin? 0 shadow-width)]
{:horizontal (mth/ceil (+ stroke-margin stroke-cap-margin shadow-width)) {:horizontal (mth/ceil (+ stroke-margin shadow-width))
:vertical (mth/ceil (+ stroke-margin stroke-cap-margin shadow-height))}))) :vertical (mth/ceil (+ stroke-margin shadow-height))})))
(defn- add-padding (defn- add-padding
[bounds padding] [bounds padding]

View File

@ -264,7 +264,7 @@
:scale))) :scale)))
(defn normalize-modifiers (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 [constraints-h constraints-v modifiers
child-bounds transformed-child-bounds parent-bounds transformed-parent-bounds] 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.geom.shapes.points :as gpo]
[app.common.types.shape.layout :as ctl])) [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 ;; We do it this way because circular dependencies
(def -child-min-width nil) (def -child-min-width nil)

View File

@ -14,7 +14,7 @@
(def conjv (fnil conj [])) (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 ;; We do it this way because circular dependencies
(def -child-min-width nil) (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. ;; 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 ;; - Maximize tracks
;; ;;
;; - Expand flexible tracks ;; - Expand flexible tracks
@ -55,7 +55,7 @@
[app.common.math :as mth] [app.common.math :as mth]
[app.common.types.shape.layout :as ctl])) [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 ;; We do it this way because circular dependencies
(def -child-min-width nil) (def -child-min-width nil)
@ -449,7 +449,7 @@
column-tracks (set-auto-base-size column-tracks children shape-cells bounds objects :column) 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) 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) 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) 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 (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] [a b c d]
(let [;; Line equation representation: ax + by + c = 0 (let [;; Line equation representation: ax + by + c = 0

View File

@ -31,21 +31,21 @@
(gpt/scale val))) (gpt/scale val)))
(defn end-hv (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] [[p0 p1 _ _] val]
(-> (gpt/to-vec p1 p0) (-> (gpt/to-vec p1 p0)
(gpt/unit) (gpt/unit)
(gpt/scale val))) (gpt/scale val)))
(defn start-vv (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] [[p0 _ _ p3] val]
(-> (gpt/to-vec p0 p3) (-> (gpt/to-vec p0 p3)
(gpt/unit) (gpt/unit)
(gpt/scale val))) (gpt/scale val)))
(defn end-vv (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] [[p0 _ _ p3] val]
(-> (gpt/to-vec p3 p0) (-> (gpt/to-vec p3 p0)
(gpt/unit) (gpt/unit)

View File

@ -283,7 +283,7 @@
[selrect transform (when (some? transform) (gmt/inverse transform))])) [selrect transform (when (some? transform) (gmt/inverse transform))]))
(defn- adjust-shape-flips (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" to check this before adjusting the selrect"
[shape points] [shape points]
(let [points' (dm/get-prop shape :points) (let [points' (dm/get-prop shape :points)

View File

@ -90,7 +90,7 @@
child-seq))) child-seq)))
(defn resolve-subtree (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] [from-id to-id objects]
(concat (concat
(->> (get-children-seq from-id objects) (->> (get-children-seq from-id objects)

View File

@ -486,41 +486,36 @@
that use assets of the given type in the given library. 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 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." (container-log :info asset-id
([changes file-id asset-type asset-id library-id libraries current-file-id] :msg "Sync file with library"
(generate-sync-file changes file-id asset-type asset-id library-id libraries current-file-id false)) :asset-type asset-type
([changes file-id asset-type asset-id library-id libraries current-file-id early-return?] :asset-id asset-id
(assert (contains? #{:colors :components :typographies} asset-type)) :file (pretty-file file-id libraries current-file-id)
(assert (or (nil? asset-id) (uuid? asset-id))) :library (pretty-file library-id libraries current-file-id))
(assert (uuid? file-id))
(assert (uuid? library-id))
(container-log :info asset-id (let [file (get-in libraries [file-id :data])]
:msg "Sync file with library" (loop [containers (ctf/object-containers-seq file)
:asset-type asset-type changes changes]
:asset-id asset-id (if-let [container (first containers)]
:file (pretty-file file-id libraries current-file-id) (do
:library (pretty-file library-id libraries current-file-id)) (recur (next containers)
(pcb/concat-changes ;;TODO Remove concat changes
(let [file (get-in libraries [file-id :data])] changes
(loop [containers (ctf/object-containers-seq file) (generate-sync-container (pcb/empty-changes nil)
changes changes] asset-type
(let [container (first containers)] asset-id
(if (or (nil? container) library-id
(and early-return? (seq (:redo-changes changes)))) container
changes libraries
(recur (next containers) current-file-id))))
(pcb/concat-changes ;;TODO Remove concat changes changes))))
changes
(generate-sync-container (pcb/empty-changes nil)
asset-type
asset-id
library-id
container
libraries
current-file-id)))))))))
(defn generate-sync-library (defn generate-sync-library
"Generate changes to synchronize all shapes in all components of the "Generate changes to synchronize all shapes in all components of the
@ -528,41 +523,35 @@
the given library. the given library.
If an asset id is given, only shapes linked to this particular asset will 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." (container-log :info asset-id
([changes file-id asset-type asset-id library-id libraries current-file-id] :msg "Sync local components with library"
(generate-sync-library changes file-id asset-type asset-id library-id libraries current-file-id false)) :asset-type asset-type
([changes file-id asset-type asset-id library-id libraries current-file-id early-return?] :asset-id asset-id
(assert (contains? #{:colors :components :typographies} asset-type)) :file (pretty-file file-id libraries current-file-id)
(assert (or (nil? asset-id) (uuid? asset-id))) :library (pretty-file library-id libraries current-file-id))
(assert (uuid? file-id))
(assert (uuid? library-id))
(container-log :info asset-id (let [file (get-in libraries [file-id :data])]
:msg "Sync local components with library" (loop [local-components (ctkl/components-seq file)
:asset-type asset-type changes changes]
:asset-id asset-id (if-let [local-component (first local-components)]
:file (pretty-file file-id libraries current-file-id) (recur (next local-components)
:library (pretty-file library-id libraries current-file-id)) (pcb/concat-changes ;;TODO Remove concat changes
changes
(let [file (get-in libraries [file-id :data])] (generate-sync-container (pcb/empty-changes nil)
(loop [local-components (ctkl/components-seq file) asset-type
changes changes] asset-id
(let [local-component (first local-components)] library-id
(if (or (nil? local-component) (cfh/make-container local-component :component)
(and early-return? (seq (:redo-changes changes)))) libraries
changes current-file-id)))
(recur (next local-components) changes))))
(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)))))))))
(defn- generate-sync-container (defn- generate-sync-container
"Generate changes to synchronize all shapes in a particular container (a page "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) ;; 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. ;; 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 ;; only update the untouched one
text-content-change? text-content-change?
(and omit-touched? (and omit-touched?
@ -2102,38 +2091,6 @@
(or (:transform current-shape) (gmt/matrix))))))) (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? (defn- equal-geometry?
"Returns true when the value of `attr` in `shape` is considered equal "Returns true when the value of `attr` in `shape` is considered equal
to the corresponding value in `origin-shape`, ignoring positional to the corresponding value in `origin-shape`, ignoring positional
@ -2238,7 +2195,7 @@
;; On texts, both text (the actual letters) ;; On texts, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content. ;; 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 ;; only update the untouched one
text-change? text-change?
(and (not skip-operations?) (and (not skip-operations?)
@ -2303,10 +2260,6 @@
(contains? #{:points :selrect :width :height} attr)) (contains? #{:points :selrect :width :height} attr))
(switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape 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 :else
(get previous-shape attr))) (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))] (generate-new-shape-for-swap shape file page libraries id-new-component index target-cell keep-props-values))]
[new-shape all-parents changes])) [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 (defn generate-sync-file-changes
([changes undo-group asset-type file-id asset-id library-id libraries current-file-id] [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)) (let [sync-components? (or (nil? asset-type) (= asset-type :components))
([changes undo-group asset-type file-id asset-id library-id libraries current-file-id early-return?] sync-colors? (or (nil? asset-type) (= asset-type :colors))
(let [sync-components? (or (nil? asset-type) (= asset-type :components)) sync-typographies? (or (nil? asset-type) (= asset-type :typographies))]
sync-colors? (or (nil? asset-type) (= asset-type :colors)) (cond-> changes
sync-typographies? (or (nil? asset-type) (= asset-type :typographies)) :always
done? (fn [c] (and early-return? (seq (:redo-changes c))))] (pcb/set-undo-group undo-group)
(-> (pcb/set-undo-group changes undo-group) ;; library-changes
;; library-changes sync-components?
(maybe-sync sync-components? done? #(generate-sync-library % file-id :components asset-id library-id libraries current-file-id early-return?)) (generate-sync-library file-id :components asset-id library-id libraries current-file-id)
(maybe-sync sync-colors? done? #(generate-sync-library % file-id :colors asset-id library-id libraries current-file-id early-return?)) sync-colors?
(maybe-sync sync-typographies? done? #(generate-sync-library % file-id :typographies asset-id library-id libraries current-file-id early-return?)) (generate-sync-library file-id :colors asset-id library-id libraries current-file-id)
;; file-changes sync-typographies?
(maybe-sync sync-components? done? #(generate-sync-file % file-id :components asset-id library-id libraries current-file-id early-return?)) (generate-sync-library file-id :typographies asset-id library-id libraries current-file-id)
(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?))))))
;; 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 (defn generate-sync-head
[changes file-full libraries container id reset?] [changes file-full libraries container id reset?]

View File

@ -539,12 +539,5 @@
(update shape :interactions ctsi/add-interaction interaction)) (update shape :interactions ctsi/add-interaction interaction))
(defn show-in-viewer (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] [shape]
(if (true? (:hide-in-viewer shape)) (dissoc shape :hide-in-viewer))
shape
(dissoc shape :hide-in-viewer)))

View File

@ -202,24 +202,6 @@
(zero? result) false (zero? result) false
:else 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? (defn inst?
[o] [o]
#?(:clj (instance? Instant o) #?(:clj (instance? Instant o)

View File

@ -159,7 +159,7 @@
group))) group)))
(defn component-attr? (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 Note that design tokens also are involved, although they go by an alternate
route and thus they are not part of :sync-attrs. route and thus they are not part of :sync-attrs.
Also when detaching a nested copy it also needs to trigger a synchronization, 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?)))) (some? (find-component-main objects shape only-direct-child?))))
(defn in-any-component? (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." head or not."
[objects shape] [objects shape]
(or (ctk/in-component-copy? shape) (or (ctk/in-component-copy? shape)
@ -405,7 +405,7 @@
(map remap-ids new-shapes)]))) (map remap-ids new-shapes)])))
(defn get-first-valid-parent (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." a variant container."
[objects id] [objects id]
(let [shape (get objects id)] (let [shape (get objects id)]
@ -517,7 +517,7 @@
:any-main-descendant any-main-descendant})) :any-main-descendant any-main-descendant}))
(defn find-valid-parent-and-frame-ids (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] ([parent-id objects children]
(find-valid-parent-and-frame-ids parent-id objects children false nil nil)) (find-valid-parent-and-frame-ids parent-id objects children false nil nil))
([parent-id objects children pasting? libraries] ([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 => "Implements a specialized map-like data structure for store an UUID =>
OBJECT mappings. The main purpose of this data structure is be able 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 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. 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 only works by reference equality and the hash-code is calculated
properly from each value." properly from each value."

View File

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

View File

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

View File

@ -30,7 +30,6 @@
[app.common.types.shape.layout :as ctsl] [app.common.types.shape.layout :as ctsl]
[app.common.types.shape.shadow :as ctss] [app.common.types.shape.shadow :as ctss]
[app.common.types.shape.text :as ctsx] [app.common.types.shape.text :as ctsx]
[app.common.types.stroke :as stroke]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.types.token :as cto] [app.common.types.token :as cto]
[app.common.types.variant :as ctv] [app.common.types.variant :as ctv]
@ -62,8 +61,8 @@
(map->Shape attrs)) (map->Shape attrs))
:clj (map->Shape attrs))) :clj (map->Shape attrs)))
(def stroke-caps-line stroke/stroke-caps-line) (def stroke-caps-line #{:round :square})
(def stroke-caps-marker stroke/stroke-caps-marker) (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 stroke-caps (conj (set/union stroke-caps-line stroke-caps-marker) nil))
(def shape-types (def shape-types
@ -138,8 +137,6 @@
[:stroke-style {:optional true} [:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]] [::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number] [: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} [:stroke-alignment {:optional true}
[::sm/one-of #{:center :inner :outer}]] [::sm/one-of #{:center :inner :outer}]]
[:stroke-cap-start {:optional true} [:stroke-cap-start {:optional true}
@ -526,7 +523,7 @@
:fills [] :fills []
:strokes [{:stroke-style :solid :strokes [{:stroke-style :solid
:stroke-alignment :inner :stroke-alignment :inner
:stroke-width 1 :stroke-width 2
:stroke-color clr/black :stroke-color clr/black
:stroke-opacity 1}]}) :stroke-opacity 1}]})
@ -730,7 +727,7 @@
(cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape)))))) (cond-> (ctsl/any-layout? shape) (extract-layout-attrs shape))))))
(defn patch-props (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] [shape props objects]
(letfn [(patch-text-props [shape props] (letfn [(patch-text-props [shape props]

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