Merge branch 'develop' into staging

This commit is contained in:
Andrey Antukh 2026-06-22 09:52:28 +02:00
commit 19a851aacb
717 changed files with 83430 additions and 65174 deletions

133
.devenv/README.md Normal file
View File

@ -0,0 +1,133 @@
# `.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

@ -0,0 +1,204 @@
#!/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

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

View File

@ -0,0 +1,8 @@
# 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

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

View File

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

View File

@ -0,0 +1,12 @@
{
"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

@ -0,0 +1,10 @@
# 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

@ -0,0 +1,14 @@
{
"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

@ -0,0 +1,12 @@
{
"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,22 +0,0 @@
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"

10
.gitignore vendored
View File

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

View File

@ -1,5 +1,117 @@
# 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.1
### :sparkles: New features & Enhancements
@ -23,6 +135,7 @@
### :boom: Breaking changes & Deprecations
### :rocket: Epics and highlights
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
@ -78,6 +191,8 @@
- Enable multi-instance horizontal scaling for MCP server [#10000](https://github.com/penpot/penpot/issues/10000) (PR: [#10013](https://github.com/penpot/penpot/pull/10013))
### :bug: Bugs fixed
- Fix plugin API `Board.addRulerGuide` attaching guides to the page instead of the board due to a shadowed `id` binding; also correct the `'content:write'` permission error message and the `RulerGuideProxy` name (by @girafic) [#8225](https://github.com/penpot/penpot/issues/8225) (PR: [#8632](https://github.com/penpot/penpot/pull/8632))
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
@ -186,7 +301,6 @@
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
## 2.15.1
### :sparkles: New features & Enhancements
@ -197,7 +311,6 @@
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138))
## 2.15.0
### :sparkles: New features & Enhancements

View File

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

View File

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

View File

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

View File

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

View File

@ -52,10 +52,6 @@
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
org.jsoup/jsoup {:mvn/version "1.22.2"}
org.im4java/im4java
{:git/tag "1.4.0-penpot-2"
:git/sha "e2b3e16"
:git/url "https://github.com/penpot/im4java"}
at.yawk.lz4/lz4-java
{:mvn/version "1.11.0"}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
@ -254,4 +254,4 @@
</div>
</body>
</html>
</html>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -29,10 +29,8 @@
style="vertical-align:top;" width="100%">
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the first Open Source design and prototyping platform meant for
cross-domain teams.
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
Penpot is the open-source design platform for teams that need scalable collaboration.
</div>
</td>
</tr>
@ -100,9 +98,9 @@
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://penpot.app/" target="_blank">
<a href="https://www.reddit.com/r/Penpot" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-uxbox.png"
src="{{ public-uri }}/images/email/logo-reddit.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
@ -126,9 +124,9 @@
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/penpotapp" target="_blank">
<a href="https://www.linkedin.com/company/penpot" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-x.png"
src="{{ public-uri }}/images/email/logo-linkedin.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
@ -152,9 +150,9 @@
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/penpot/" target="_blank">
<a href="https://bsky.app/profile/penpot.app" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-github.png"
src="{{ public-uri }}/images/email/logo-bluesky.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
@ -178,10 +176,36 @@
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/penpotdesign/"
<a href="https://github.com/penpot" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-github.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/penpotapp"
target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-linkedin.png"
src="{{ public-uri }}/images/email/logo-x.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
@ -205,9 +229,9 @@
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://fosstodon.org/@penpot/" target="_blank">
<a href="https://www.youtube.com/@Penpot" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-mastodon.png"
src="{{ public-uri }}/images/email/logo-youtube.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
@ -231,10 +255,36 @@
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://tree.taiga.io/project/penpot"
<a href="https://www.instagram.com/penpot.app"
target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-taiga.png"
src="{{ public-uri }}/images/email/logo-instagram.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!--[if mso | IE]>
</td>
<td>
<![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="float:none;display:inline-table;">
<tr>
<td style="padding:0 8px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-radius:3px;width:24px;">
<tr>
<td
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://fosstodon.org/@penpot" target="_blank">
<img height="24"
src="{{ public-uri }}/images/email/logo-mastodon.svg"
style="border-radius:3px;display:block;"
width="24" />
</a>
@ -320,4 +370,4 @@
</td>
</tr>
</table>
<![endif]-->
<![endif]-->

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
@ -198,14 +198,14 @@
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
<tr>
<td width="20" height="20" align="center" valign="middle"
background="{{organization-logo}}"
background="{% if organization.logo %}{{organization.logo}}{% else %}{{organization.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 organization-initials %}{{organization-initials}}{% endif %}
{% if organization.initials %}{{organization.initials}}{% endif %}
</td>
</tr>
</table>
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
“{{ organization-name|abbreviate:25 }}”
{{ organization.name|abbreviate:50 }}
</span>
</div>
</td>

View File

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

View File

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

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
@ -241,4 +241,4 @@
</div>
</body>
</html>
</html>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
@ -249,4 +249,4 @@
</div>
</body>
</html>
</html>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>
@ -179,15 +179,21 @@
<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:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
Hello {{name|abbreviate:25}}!</div>
Hi {{name|abbreviate:25}},</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;">
Thanks for signing up for your Penpot account! Please verify your email using the link below and
get started building mockups and prototypes today!</div>
Welcome to Penpot!</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;">
Please verify your email to get started with your first design and collaboration.</div>
</td>
</tr>
<tr>
@ -218,7 +224,7 @@
<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>
The Penpot team</div>
</td>
</tr>
</table>
@ -241,4 +247,4 @@
</div>
</body>
</html>
</html>

View File

@ -1 +1 @@
Verify email.
Verify your Penpot account

View File

@ -1,9 +1,11 @@
Hello {{name|abbreviate:25}}!
Hi {{name|abbreviate:25}},
Thanks for signing up for your Penpot account! Please verify your email using the
link below and get started building mockups and prototypes today!
Welcome to Penpot!
Please verify your email to get started with your first design and collaboration.
{{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy!
The Penpot team.
The Penpot team

View File

@ -0,0 +1,270 @@
<!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/logo-penpot.svg"
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

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

View File

@ -0,0 +1,17 @@
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

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -122,7 +122,7 @@
<tbody>
<tr>
<td style="width:97px;">
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
<img height="32" src="{{ public-uri }}/images/email/logo-penpot.svg"
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
width="97" />
</td>

View File

@ -1,45 +1,45 @@
[{:id "tokens-starter-kit"
:name "Design tokens starter kit"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Tokens%20starter%20kit.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/refs/heads/main/Tokens%20starter%20kit.penpot"}
{:id "penpot-design-system"
:name "Penpot Design System | Pencil"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/refs/heads/main/Pencil-Penpot-Design-System.penpot"}
{:id "wireframing-kit"
:name "Wireframe library"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/refs/heads/main/Wireframing%20kit%20v1.1.penpot"}
{:id "prototype-examples"
:name "Prototype template"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Prototype%20examples%20v1.1.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/refs/heads/main/Prototype%20examples%20v1.1.penpot"}
{:id "plants-app"
:name "UI mockup example"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Plants-app.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/Plants-app.penpot"}
{:id "tutorial-for-beginners"
:name "Tutorial for beginners"
:file-uri "https://github.com/penpot/penpot-files/raw/main/tutorial-for-beginners.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/tutorial-for-beginners.penpot"}
{:id "lucide-icons"
:name "Lucide Icons"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Lucide-icons.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/Lucide-icons.penpot"}
{:id "font-awesome"
:name "Font Awesome"
:file-uri "https://github.com/penpot/penpot-files/raw/main/FontAwesome.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/FontAwesome.penpot"}
{:id "black-white-mobile-templates"
:name "Black & White Mobile Templates"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Black-&-White-Mobile-Templates.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/Black-&-White-Mobile-Templates.penpot"}
{:id "avataaars"
:name "Avataaars"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Avataaars-by-Pablo-Stanley.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/Avataaars-by-Pablo-Stanley.penpot"}
{:id "ux-notes"
:name "UX Notes"
:file-uri "https://github.com/penpot/penpot-files/raw/main/UX-Notes.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/UX-Notes.penpot"}
{:id "whiteboarding-kit"
:name "Whiteboarding Kit"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Whiteboarding-mapping-kit.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/Whiteboarding-mapping-kit.penpot"}
{:id "open-color-scheme"
:name "Open Color Scheme"
:file-uri "https://github.com/penpot/penpot-files/raw/main/Open%20Color%20Scheme%20(v1.9.1).penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/Open%20Color%20Scheme%20(v1.9.1).penpot"}
{:id "flex-layout-playground"
:name "Flex Layout Playground"
:file-uri "https://github.com/penpot/penpot-files/raw/refs/heads/main/Flex%20Layout%20Playground%20v2.0.penpot"}
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/refs/heads/main/Flex%20Layout%20Playground%20v2.0.penpot"}
{:id "welcome"
:name "Welcome"
:file-uri "https://github.com/penpot/penpot-files/raw/main/welcome.penpot"}]
:file-uri "https://raw.githubusercontent.com/penpot/penpot-files/main/welcome.penpot"}]

View File

@ -77,7 +77,6 @@ export JAVA_OPTS="\
-Djdk.attach.allowAttachSelf \
-Dlog4j2.configurationFile=log4j2-devenv.xml \
-Djdk.tracePinnedThreads=full \
-Dim4java.useV7=true \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseShenandoahGC \
-XX:+UseCompactObjectHeaders \

View File

@ -17,7 +17,6 @@ mv target/penpot.jar target/dist/penpot.jar
cp resources/log4j2.xml target/dist/log4j2.xml
cp scripts/run.template.sh target/dist/run.sh;
cp scripts/manage.py target/dist/manage.py
cp scripts/svgo-cli.js target/dist/scripts/;
chmod +x target/dist/run.sh;
chmod +x target/dist/manage.py

View File

@ -18,7 +18,7 @@ if [ -f ./environ ]; then
source ./environ
fi
export JAVA_OPTS="-Dim4java.useV7=true -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
ENTRYPOINT=${1:-app.main};

File diff suppressed because one or more lines are too long

View File

@ -24,9 +24,11 @@
[app.http.errors :as errors]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.nitrate :as nitrate]
[app.rpc.commands.profile :as profile]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.cache :as cache]
[app.util.inet :as inet]
[app.util.json :as json]
[buddy.sign.jwk :as jwk]
@ -41,9 +43,9 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- discover-oidc-config
[cfg {:keys [base-uri] :as provider}]
[cfg {:keys [base-uri skip-ssrf-check?] :as provider}]
(let [uri (u/join base-uri ".well-known/openid-configuration")
rsp (http/req cfg {:method :get :uri (dm/str uri)})]
rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 (:status rsp))
(let [data (-> rsp :body json/decode)
@ -104,8 +106,8 @@
keys))
(defn- fetch-oidc-jwks
[cfg jwks-uri]
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
(if (= 200 status)
(-> body json/decode :keys process-oidc-jwks)
(ex/raise :type ::internal
@ -117,7 +119,8 @@
"Fetch and Add (if possible) JWK's to the OIDC provider"
[cfg provider]
(try
(if-let [jwks (some->> (:jwks-uri provider) (fetch-oidc-jwks cfg))]
(if-let [jwks (when-let [jwks-uri (:jwks-uri provider)]
(fetch-oidc-jwks cfg jwks-uri {:skip-ssrf-check? (:skip-ssrf-check? provider)}))]
(assoc provider :jwks jwks)
provider)
(catch Throwable cause
@ -408,9 +411,9 @@
(defn- build-redirect-uri
[]
(let [public (u/uri (cf/get :public-uri))]
(str (assoc public :path (str "/api/auth/oidc/callback")))))
(str (assoc public :path "/api/auth/oidc/callback"))))
(defn- build-auth-redirect-uri
(defn build-auth-redirect-uri
[provider token]
(let [params {:client_id (:client-id provider)
:redirect_uri (build-redirect-uri)
@ -453,7 +456,7 @@
:grant-type (:grant_type params)
:redirect-uri (:redirect_uri params))
(let [{:keys [status body]} (http/req cfg req)]
(let [{:keys [status body]} (http/req cfg req {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
(if (= status 200)
(let [data (json/decode body)
data {:token/access (get data :access_token)
@ -508,7 +511,7 @@
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
:timeout 6000
:method :get}
response (http/req cfg params)]
response (http/req cfg params {:skip-ssrf-check? (:skip-ssrf-check? provider)})]
(l/trc :hint "user info response"
:status (:status response)
@ -694,15 +697,24 @@
(db/pgarray? roles)
(assoc :roles (db/decode-pgarray roles #{}))))
;; TODO: add cache layer for avoid build an discover each time
;; A short TTL avoids paying the OIDC discovery + JWKS fetch on every
;; login; Caffeine will not store the entry when the load fn throws,
;; so a transient failure at the provider's discovery endpoint does
;; not poison the cache.
(defonce ^:private provider-cache
(cache/create :expire "10m" :max-size 64))
(defn- load-provider
[cfg id]
(when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true})
(decode-row))]
(case (:type params)
"oidc" (prepare-oidc-provider cfg params))))
(defn get-provider
[cfg id]
(try
(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)))
(cache/get provider-cache id (partial load-provider cfg))
(catch Throwable cause
(l/err :hint "unable to configure custom SSO provider"
:provider (str id)
@ -745,6 +757,25 @@
(assoc profile :props props'))
profile)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn prepare-org-sso-provider
"Build an OIDC provider map dynamically from the Nitrate org SSO config.
Uses OIDC discovery via :base-url (or :issuer as fallback) when
token/auth/user URIs are absent."
[cfg {:keys [client-id client-secret base-url issuer scopes]}]
(prepare-oidc-provider cfg
{:type "oidc"
:client-id client-id
:client-secret client-secret
:base-uri (some-> (or base-url issuer)
(str/rtrim "/")
(str "/"))
:scopes (into default-oidc-scopes (or scopes #{}))
:skip-ssrf-check? true}))
(defn- auth-handler
[cfg {:keys [params] :as request}]
(let [provider (resolve-provider cfg params)
@ -769,64 +800,81 @@
(try
(let [code (get params :code)
state (get params :state)
state (tokens/verify cfg {:token state :iss "oidc"})
state (tokens/verify cfg {:token state :iss "oidc"})]
provider (resolve-provider cfg state)
info (get-info cfg provider state code)
profile (get-profile cfg (:email info))]
;; Org SSO flow: state carries :dest-url — exchange the authorization
;; code with the OIDC provider to verify authentication actually occurred.
(if-let [dest-url (:dest-url state)]
(let [team-id (:team-id state)
organization-id (:organization-id state)
sso (nitrate/call cfg :get-org-sso-by-team {:team-id team-id})
provider (prepare-org-sso-provider cfg sso)
;; verify token or throw error
_info (get-info cfg provider state code)
session (session/get-session request)
exp (ct/in-future {:hours 48})]
(when (and session organization-id)
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(redirect-response dest-url))
(cond
(not profile)
(cond
(and (email.blacklist/enabled? cfg)
(email.blacklist/contains? cfg (:email info)))
(redirect-with-error "email-domain-not-allowed")
(let [provider (resolve-provider cfg state)
info (get-info cfg provider state code)
profile (get-profile cfg (:email info))]
(and (email.whitelist/enabled? cfg)
(not (email.whitelist/contains? cfg (:email info))))
(redirect-with-error "email-domain-not-allowed")
(cond
(not profile)
(cond
(and (email.blacklist/enabled? cfg)
(email.blacklist/contains? cfg (:email info)))
(redirect-with-error "email-domain-not-allowed")
:else
(if (or (contains? cf/flags :registration)
(contains? cf/flags :oidc-registration))
(redirect-to-register cfg info provider)
(redirect-with-error "registration-disabled")))
(and (email.whitelist/enabled? cfg)
(not (email.whitelist/contains? cfg (:email info))))
(redirect-with-error "email-domain-not-allowed")
(:is-blocked profile)
(redirect-with-error "profile-blocked")
:else
(if (or (contains? cf/flags :registration)
(contains? cf/flags :oidc-registration))
(redirect-to-register cfg info provider)
(redirect-with-error "registration-disabled")))
(not (or (= (:auth-backend profile) (:type provider))
(profile-has-provider-props? provider profile)
(provider-has-email-verified? provider info)))
(redirect-with-error "auth-provider-not-allowed")
(:is-blocked profile)
(redirect-with-error "profile-blocked")
(not (:is-active profile))
(let [info (assoc info :profile-id (:id profile))]
(redirect-to-register cfg info provider))
(not (or (= (:auth-backend profile) (:type provider))
(profile-has-provider-props? provider profile)
(provider-has-email-verified? provider info)))
(redirect-with-error "auth-provider-not-allowed")
:else
(let [sxf (session/create-fn cfg profile info)
token (or (:invitation-token info)
(tokens/generate cfg
{:iss :auth
:exp (ct/in-future "15m")
:profile-id (:id profile)}))
(not (:is-active profile))
(let [info (assoc info :profile-id (:id profile))]
(redirect-to-register cfg info provider))
;; If proceed, update profile on the database
profile (update-profile-with-info cfg profile info)
:else
(let [sxf (session/create-fn cfg profile info)
token (or (:invitation-token info)
(tokens/generate cfg
{:iss :auth
:exp (ct/in-future "15m")
:profile-id (:id profile)}))
props (audit/profile->props profile)
context (d/without-nils {:external-session-id (:external-session-id info)})]
;; If proceed, update profile on the database
profile (update-profile-with-info cfg profile info)
(audit/submit cfg {:type "action"
:name "login-with-oidc"
:profile-id (:id profile)
:ip-addr (inet/parse-request request)
:props props
:context context})
props (audit/profile->props profile)
context (d/without-nils {:external-session-id (:external-session-id info)})]
(->> (redirect-to-verify-token token)
(sxf request)))))
(audit/submit cfg {:type "action"
:name "login-with-oidc"
:profile-id (:id profile)
:ip-addr (inet/parse-request request)
:props props
:context context})
(->> (redirect-to-verify-token token)
(sxf request)))))))
(catch Throwable cause
(binding [l/*context* (errors/request->context request)]
@ -839,6 +887,7 @@
::http/client
::setup/props
::db/pool
[:app.nitrate/client [:maybe :map]]
[::providers schema:providers]])
(defmethod ig/assert-key ::routes

View File

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

View File

@ -74,6 +74,10 @@
:media-max-file-size (* 1024 1024 30) ; 30MiB
:font-max-file-size (* 1024 1024 30) ; 30MiB
:font-process-mem 512 ;; 512 MiB address space ceiling
:font-process-cpu 30 ;; 30 seconds CPU time
:font-process-timeout 60 ;; 60 seconds wall-clock
:ldap-user-query "(|(uid=:username)(mail=:username))"
:ldap-attrs-username "uid"
:ldap-attrs-email "mail"
@ -109,6 +113,11 @@
[:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int]
;; Explicit CORS allowlist used when the :cors flag is enabled.
;; Configured via PENPOT_ALLOWED_ORIGINS as a comma/whitespace
;; separated list of origins (e.g. "https://plugins.example.com").
[:allowed-origins {:optional true} [::sm/set :string]]
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:nexus-shared-key {:optional true} :string]
@ -122,6 +131,22 @@
[:media-max-file-size {:optional true} ::sm/int]
[:font-max-file-size {:optional true} ::sm/int]
;; Font processing resource limits (PENPOT_FONT_PROCESS_*)
[:font-process-mem {:optional true} ::sm/int]
[:font-process-cpu {:optional true} ::sm/int]
[:font-process-timeout {:optional true} ::sm/int]
;; ImageMagick resource limits (PENPOT_IMAGEMAGICK_*)
[:imagemagick-thread-limit {:optional true} :string]
[:imagemagick-memory-limit {:optional true} :string]
[:imagemagick-map-limit {:optional true} :string]
[:imagemagick-area-limit {:optional true} :string]
[:imagemagick-disk-limit {:optional true} :string]
[:imagemagick-time-limit {:optional true} :string]
[:imagemagick-width-limit {:optional true} :string]
[:imagemagick-height-limit {:optional true} :string]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
@ -236,7 +261,6 @@
[:assets-path {:optional true} :string]
[:netty-io-threads {:optional true} ::sm/int]
[:executor-threads {:optional true} ::sm/int]
[:nitrate-backend-uri {:optional true} ::sm/uri]
@ -295,7 +319,7 @@
(sm/explainer schema:config))
(defn read-config
"Reads the configuration from enviroment variables and decodes all
"Reads the configuration from environment variables and decodes all
known values."
[& {:keys [prefix default] :or {prefix "penpot"}}]
(->> (read-env prefix)

View File

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

View File

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

View File

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

View File

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

View File

@ -68,17 +68,24 @@
(def ^:private valid-params?
(sm/validator schema:params))
(defn- decode-session
[session]
(cond-> session
(db/pgobject? (:props session))
(update :props db/decode-transit-pgobject)))
(defn- database-manager
[pool]
(reify ISessionManager
(read-session [_ id]
(if (string? id)
;; Backward compatibility
;; Backward compatibility: http_session (v1) has no props column
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
(-> session
(assoc :modified-at (:updated-at session))
(dissoc :updated-at)))
(db/exec-one! pool (sql/select :http-session-v2 {:id id}))))
(some-> (db/exec-one! pool (sql/select :http-session-v2 {:id id}))
(decode-session))))
(create-session [_ params]
(assert (valid-params? params) "expect valid session params")
@ -100,7 +107,9 @@
(assoc :created-at modified-at)
(assoc :modified-at modified-at)))
(db/update! pool :http-session-v2
{:modified-at modified-at}
(cond-> {:modified-at modified-at}
(some? (:props session))
(assoc :props (db/tjson (:props session))))
{:id (:id session)}
{::db/return-keys true}))))
@ -129,9 +138,10 @@
session))
(update-session [_ session]
(let [modified-at (ct/now)]
(swap! cache update (:id session) assoc :modified-at modified-at)
(assoc session :modified-at modified-at)))
(let [modified-at (ct/now)
session (assoc session :modified-at modified-at)]
(swap! cache assoc (:id session) session)
session))
(delete-session [_ id]
(swap! cache dissoc id)
@ -216,6 +226,20 @@
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
(db/get-update-count))))
(def ^:private sql:clear-org-sso-sessions
(str "UPDATE http_session_v2 "
"SET props = props #- ARRAY['~:sso', ?]::text[] "
"WHERE props IS NOT NULL "
"AND jsonb_exists(props -> '~:sso', ?)"))
(defn clear-org-sso-sessions!
"Remove the SSO entry for organization-id from the props of every
session that currently holds it. The key is transit-encoded as the
string '~u<uuid>' under the '~:sso' path."
[pool organization-id]
(let [org-key (str "~u" organization-id)]
(db/exec! pool [sql:clear-org-sso-sessions org-key org-key])))
(defn- renew-session?
[{:keys [id modified-at] :as session}]
(or (string? id)

View File

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

View File

@ -38,7 +38,6 @@
[app.storage.gc-deleted :as-alias sto.gc-deleted]
[app.storage.gc-touched :as-alias sto.gc-touched]
[app.storage.s3 :as-alias sto.s3]
[app.svgo :as-alias svgo]
[app.util.cron]
[app.worker :as-alias wrk]
[app.worker.executor]
@ -162,8 +161,8 @@
::wrk/netty-io-executor
{:threads (cf/get :netty-io-threads)}
::wrk/netty-executor
{:threads (cf/get :executor-threads)}
::wrk/executor
{}
:app.migrations/migrations
{::db/pool (ig/ref ::db/pool)}
@ -178,9 +177,6 @@
{::rds/uri
(cf/get :redis-uri)
::wrk/netty-executor
(ig/ref ::wrk/netty-executor)
::wrk/netty-io-executor
(ig/ref ::wrk/netty-io-executor)}
@ -189,12 +185,12 @@
::mtx/metrics (ig/ref ::mtx/metrics)}
::mbus/msgbus
{::wrk/executor (ig/ref ::wrk/netty-executor)
{::wrk/executor (ig/ref ::wrk/executor)
::rds/client (ig/ref ::rds/client)
::mtx/metrics (ig/ref ::mtx/metrics)}
:app.storage.tmp/cleaner
{::wrk/executor (ig/ref ::wrk/netty-executor)}
{::wrk/executor (ig/ref ::wrk/executor)}
::sto.gc-deleted/handler
{::db/pool (ig/ref ::db/pool)
@ -265,7 +261,8 @@
::oidc/providers (ig/ref ::oidc/providers)
::session/manager (ig/ref ::session/manager)
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
::email/whitelist (ig/ref ::email/whitelist)
:app.nitrate/client (ig/ref :app.nitrate/client)}
::mgmt/routes
{::db/pool (ig/ref ::db/pool)
@ -308,12 +305,12 @@
::rpc/climit
{::mtx/metrics (ig/ref ::mtx/metrics)
::wrk/executor (ig/ref ::wrk/netty-executor)
::wrk/executor (ig/ref ::wrk/executor)
::climit/config (cf/get :rpc-climit-config)
::climit/enabled (contains? cf/flags :rpc-climit)}
:app.rpc/rlimit
{::wrk/executor (ig/ref ::wrk/netty-executor)
{::wrk/executor (ig/ref ::wrk/executor)
:app.loggers.mattermost/reporter
(ig/ref :app.loggers.mattermost/reporter)
@ -325,8 +322,8 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/netty-executor)
:app.nitrate/client (ig/ref :app.nitrate/client)
::wrk/executor (ig/ref ::wrk/executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
::sto/storage (ig/ref ::sto/storage)
@ -356,12 +353,12 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
::wrk/executor (ig/ref ::wrk/netty-executor)
::wrk/executor (ig/ref ::wrk/executor)
::session/manager (ig/ref ::session/manager)
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
:app.nitrate/client (ig/ref :app.nitrate/client)
:app.nitrate/client (ig/ref :app.nitrate/client)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}

View File

@ -21,9 +21,9 @@
[app.media.sanitize :as sanitize]
[app.storage :as-alias sto]
[app.storage.tmp :as tmp]
[app.util.shell :as shell]
[buddy.core.bytes :as bb]
[buddy.core.codecs :as bc]
[clojure.java.shell :as sh]
[clojure.string]
[clojure.xml :as xml]
[cuerdas.core :as str]
@ -34,9 +34,7 @@
java.io.InputStream
javax.xml.parsers.SAXParserFactory
javax.xml.XMLConstants
org.apache.commons.io.IOUtils
org.im4java.core.ConvertCmd
org.im4java.core.IMOperation))
org.apache.commons.io.IOUtils))
(def schema:upload
[:map {:title "Upload"}
@ -90,25 +88,17 @@
max-size)))
upload))
(defmulti process :cmd)
(defmulti process-error class)
(defmulti process (fn [_system params] (:cmd params)))
(defmethod process :default
[{:keys [cmd] :as params}]
[_system {:keys [cmd] :as params}]
(ex/raise :type :internal
:code :not-implemented
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
(defmethod process-error :default
[error]
(throw error))
(defn run
[params]
(try
(process params)
(catch Throwable e
(process-error e))))
[system params]
(process system params))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SVG PARSING
@ -152,16 +142,63 @@
;; Related info on how thumbnails generation
;; http://www.imagemagick.org/Usage/thumbnails/
(def ^:private imagemagick-default-env
"Default environment variables for ImageMagick resource limits.
These are the soft ceiling policy.xml is the hard ceiling."
{"MAGICK_THREAD_LIMIT" "2"
"MAGICK_MEMORY_LIMIT" "256MiB"
"MAGICK_MAP_LIMIT" "512MiB"
"MAGICK_AREA_LIMIT" "128MP"
"MAGICK_DISK_LIMIT" "1GiB"
"MAGICK_TIME_LIMIT" "30"})
(defn- get-imagemagick-env
"Returns environment variables for ImageMagick commands.
Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults."
[]
(let [thread (cf/get :imagemagick-thread-limit)
memory (cf/get :imagemagick-memory-limit)
map-l (cf/get :imagemagick-map-limit)
area (cf/get :imagemagick-area-limit)
disk (cf/get :imagemagick-disk-limit)
time (cf/get :imagemagick-time-limit)
width (cf/get :imagemagick-width-limit)
height (cf/get :imagemagick-height-limit)]
(cond-> imagemagick-default-env
thread (assoc "MAGICK_THREAD_LIMIT" thread)
memory (assoc "MAGICK_MEMORY_LIMIT" memory)
map-l (assoc "MAGICK_MAP_LIMIT" map-l)
area (assoc "MAGICK_AREA_LIMIT" area)
disk (assoc "MAGICK_DISK_LIMIT" disk)
time (assoc "MAGICK_TIME_LIMIT" time)
width (assoc "MAGICK_WIDTH_LIMIT" width)
height (assoc "MAGICK_HEIGHT_LIMIT" height))))
(defn- exec-magick!
"Execute an ImageMagick command with resource limits.
`args` is a vector of string arguments to pass to `magick`."
[system args]
(let [cmd (into ["magick"] args)
result (shell/exec! system
:cmd cmd
:env (get-imagemagick-env)
:timeout 60)]
(when (not= 0 (:exit result))
(ex/raise :type :internal
:code :imagemagick-error
:hint (str "ImageMagick command failed: " (:err result))
:cmd cmd
:exit (:exit result)))
result))
(defn- generic-process
[{:keys [input format operation] :as params}]
[system {:keys [input format convert-args] :as params}]
(let [{:keys [path mtype]} input
format (or (cm/mtype->format mtype) format)
format (or format (cm/mtype->format mtype))
ext (cm/format->extension format)
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)]
(doto (ConvertCmd.)
(.run operation (into-array (map str [path tmp]))))
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
(exec-magick! system args)
(assoc params
:format format
:mtype (cm/format->mtype format)
@ -169,38 +206,26 @@
:data tmp)))
(defmethod process :generic-thumbnail
[params]
[system params]
(let [{:keys [quality width height] :as params}
(check-thumbnail-params params)
operation
(doto (IMOperation.)
(.addImage)
(.autoOrient)
(.strip)
(.thumbnail ^Integer (int width) ^Integer (int height) ">")
(.quality (double quality))
(.addImage))]
(generic-process (assoc params :operation operation))))
(check-thumbnail-params params)]
(generic-process system
(assoc params
:convert-args ["-auto-orient" "-strip"
"-thumbnail" (str width "x" height ">")
"-quality" (str quality)]))))
(defmethod process :profile-thumbnail
[params]
[system params]
(let [{:keys [quality width height] :as params}
(check-thumbnail-params params)
operation
(doto (IMOperation.)
(.addImage)
(.autoOrient)
(.strip)
(.thumbnail ^Integer (int width) ^Integer (int height) "^")
(.gravity "center")
(.extent (int width) (int height))
(.quality (double quality))
(.addImage))]
(generic-process (assoc params :operation operation))))
(check-thumbnail-params params)]
(generic-process system
(assoc params
:convert-args ["-auto-orient" "-strip"
"-thumbnail" (str width "x" height "^")
"-gravity" "center"
"-extent" (str width "x" height)
"-quality" (str quality)]))))
(defn get-basic-info-from-svg
[{:keys [tag attrs] :as data}]
@ -230,11 +255,11 @@
{:width (int width)
:height (int height)})))]))
(defn- get-dimensions-with-orientation [^String path]
(defn- get-dimensions-with-orientation [system ^String path]
;; Image magick doesn't give info about exif rotation so we use the identify command
;; If we are processing an animated gif we use the first frame with -scene 0
(let [dim-result (sh/sh "identify" "-format" "%w %h\n" path)
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
(when (= 0 (:exit dim-result))
(let [[w h] (-> (:out dim-result)
str/trim
@ -249,7 +274,7 @@
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
(defmethod process :info
[{:keys [input] :as params}]
[system {:keys [input] :as params}]
(let [{:keys [path mtype] :as input} (check-input input)]
(if (= mtype "image/svg+xml")
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
@ -260,7 +285,7 @@
(merge input info {:ts (ct/now) :size (fs/size path)}))
(let [path-str (str path)
identify-res (sh/sh "identify" "-format" "image/%[magick]\n" path-str)
identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str])
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
mtype' (if (zero? (:exit identify-res))
(-> identify-res
@ -273,7 +298,7 @@
:code :invalid-image
:hint "invalid image"))
{:keys [width height]}
(or (get-dimensions-with-orientation path-str)
(or (get-dimensions-with-orientation system path-str)
(do
(l/warn "Failed to read image dimensions with orientation" {:path path})
(ex/raise :type :validation
@ -291,13 +316,6 @@
:size (fs/size path)
:ts (ct/now))))))
(defmethod process-error org.im4java.core.InfoException
[error]
(ex/raise :type :validation
:code :invalid-image
:hint "invalid image"
:cause error))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -370,48 +388,80 @@
;; FONTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- get-font-prlimit
"Returns resource limits for font processing tools, read from config."
[]
{:mem (cf/get :font-process-mem)
:cpu (cf/get :font-process-cpu)})
(defn- get-font-timeout
"Returns the wall-clock timeout for font processing, read from config."
[]
(cf/get :font-process-timeout))
(defn- exec-font!
"Execute a font processing command with resource limits.
`args` is a vector of string arguments."
[system args]
(shell/exec! system
:cmd args
:prlimit (get-font-prlimit)
:timeout (get-font-timeout)))
(defmethod process :generate-fonts
[{:keys [input] :as params}]
[system {:keys [input] :as params}]
(letfn [(ttf->otf [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".otf"))
_ (io/write* finput data)
res (sh/sh "fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput)))]
(when (zero? (:exit res))
foutput)))
foutput (fs/path (str finput ".otf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput))])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(otf->ttf [data]
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".ttf"))
_ (io/write* finput data)
res (sh/sh "fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput)))]
(when (zero? (:exit res))
foutput)))
foutput (fs/path (str finput ".ttf"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
(str/fmt "Open('%s'); Generate('%s')"
(str finput)
(str foutput))])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(ttf-or-otf->woff [data]
;; NOTE: foutput is not used directly, it represents the
;; default output of the execution of the underlying
;; command.
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
foutput (fs/path (str finput ".woff"))
_ (io/write* finput data)
res (sh/sh "sfnt2woff" (str finput))]
(when (zero? (:exit res))
foutput)))
foutput (fs/path (str finput ".woff"))]
(try
(io/write* finput data)
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
(when (zero? (:exit res))
foutput))
(finally
(fs/delete finput)))))
(woff->sfnt [data]
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")
_ (io/write* finput data)
res (sh/sh "woff2sfnt" (str finput)
:out-enc :bytes)]
(when (zero? (:exit res))
(:out res))))
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
(try
(io/write* finput data)
(let [res (shell/exec! system
:cmd ["woff2sfnt" (str finput)]
:out-enc :bytes
:prlimit (get-font-prlimit)
:timeout (get-font-timeout))]
(when (zero? (:exit res))
(:out res)))
(finally
(fs/delete finput)))))
(woff2->sfnt [data]
;; woff2_decompress outputs to same directory with .ttf extension
@ -419,7 +469,7 @@
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
(try
(io/write* finput data)
(let [res (sh/sh "woff2_decompress" (str finput))]
(let [res (exec-font! system ["woff2_decompress" (str finput)])]
(if (zero? (:exit res))
foutput
(do

View File

@ -484,7 +484,10 @@
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}
{:name "0149-mod-file-library-rel-synced-at"
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}])
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}
{:name "0150-mod-http-session-v2"
:fn (mg/resource "app/migrations/sql/0150-mod-http-session-v2.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,2 @@
ALTER TABLE http_session_v2
ADD COLUMN props jsonb NULL;

View File

@ -7,6 +7,7 @@
(ns app.nitrate
"Module that make calls to the external nitrate aplication"
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l]
@ -16,6 +17,7 @@
[app.common.types.organization :as cto]
[app.config :as cf]
[app.http.client :as http]
[app.http.session :as session]
[app.rpc :as-alias rpc]
[app.setup :as-alias setup]
[clojure.core :as c]
@ -28,14 +30,16 @@
(defn- request-builder
[cfg method uri shared-key profile-id request-params]
(fn []
(http/req cfg (cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
(http/req cfg
(cond-> {:method method
:headers {"content-type" "application/json"
"accept" "application/json"
"x-shared-key" shared-key
"x-profile-id" (str profile-id)}
:uri uri
:version :http1.1}
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
{:skip-ssrf-check? true})))
(defn- with-retries
[handler max-retries]
@ -59,14 +63,29 @@
(fn []
(let [response (handler)
status (:status response)]
(when-not status
(l/error :hint "could't do the nitrate request, it is probably down"
:uri uri)
;; TODO decide what to do when Nitrate is inaccesible
nil)
(cond
(nil? status)
(do
(l/error :hint "couldn't do the nitrate request, it is probably down"
:uri uri)
(ex/raise :type :nitrate-unavailable
:hint (str "nitrate is unreachable at " uri)))
(>= status 500)
;; Nitrate is up enough to answer (or the proxy is) but the
;; service itself is failing; treat as unavailable so callers
;; surface the static error page.
(do
(l/error :hint "nitrate request failed with server error status"
:uri uri
:status status
:body (:body response))
(ex/raise :type :nitrate-unavailable
:status status
:hint (str "nitrate is unavailable, HTTP " status " at " uri)))
(>= status 400)
;; For error status codes (4xx, 5xx), fail immediately without validation
;; For client error status codes (4xx), fail immediately without validation
(do
(when (not= status 404) ;; Don't need to log 404
(l/error :hint "nitrate request failed with error status"
@ -171,6 +190,7 @@
"day"
"week"
"year"]]
[:manual :boolean]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
@ -256,6 +276,42 @@
[:vector schema:org-summary]
params)))
(def ^:private schema:org-summary-counts
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:team-count ::sm/int]
[:member-count ::sm/int]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
(defn- get-owned-orgs-summary-api
[cfg {:keys [profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
orgs (request-to-nitrate cfg :get
(str baseuri
"/api/users/"
profile-id
"/owned-organizations-summary")
[:vector schema:org-summary-counts]
params)]
(mapv (fn [org]
(if-let [logo-id (:logo-id org)]
(assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id))
org))
orgs)))
(defn- cleanup-deleted-penpot-user-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
"/cleanup-after-deletion")
nil params)))
(defn- set-team-org-api
[cfg {:keys [organization-id team-id is-default] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
@ -267,7 +323,7 @@
organization-id
"/add-team")
cto/schema:team-with-organization params)
custom-photo (when-let [logo-id (get-in team [:organization :logo-id])]
custom-photo (when-let [logo-id (dm/get-in team [:organization :logo-id])]
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
(cond-> team
custom-photo
@ -297,16 +353,6 @@
"/remove-user")
nil params)))
(defn- remove-profile-from-all-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
"/remove-organizations")
nil params)))
(defn- remove-team-from-org-api
[cfg {:keys [team-id organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
@ -336,6 +382,24 @@
profile-id)
schema:subscription params)))
(def ^:private schema:subscription-warning
[:maybe
[:map {:title "SubscriptionWarning"}
[:type {:optional true} ::sm/text]
[:days-from-expiry {:optional true} ::sm/int]
[:days-until-expiry {:optional true} ::sm/int]
[:expiration-date {:optional true} schema:timestamp]]])
(defn- get-subscription-warning-api
[cfg {:keys [penpot-id profile-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)
penpot-id (or penpot-id profile-id)]
(request-to-nitrate cfg :get
(str baseuri
"/api/subscription-warning/"
penpot-id)
schema:subscription-warning params)))
(defn- get-connectivity-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
@ -348,6 +412,53 @@
[:map
[:cancel-at [:maybe schema:timestamp]]])
(defn- get-org-permissions-api
[cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/permissions")
[:map
[:organization-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:permissions [:map-of :keyword :string]]]
params)))
(def ^:private schema:nitrate-sso
[:map
[:organization-id ::sm/uuid]
[:active [:maybe :boolean]]
[:provider [:maybe :string]]
[:client-id [:maybe :string]]
[:base-url [:maybe :string]]
[:client-secret [:maybe :string]]
[:issuer [:maybe :string]]
[:scopes [:maybe [::sm/set ::sm/text]]]])
(defn- get-org-sso-by-team-api
[cfg {:keys [team-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/teams/"
team-id
"/sso")
schema:nitrate-sso
params)))
(defn- get-org-members-api
[cfg {:keys [organization-id] :as params}]
(let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get
(str baseuri
"/api/organizations/"
organization-id
"/members-list")
[:vector ::sm/uuid]
params)))
(defn- redeem-activation-code-api
[cfg params]
(let [baseuri (cf/get :nitrate-backend-uri)]
@ -369,12 +480,17 @@
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
:get-org-summary (partial get-org-summary-api cfg)
:get-owned-orgs (partial get-owned-orgs-api cfg)
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
:get-org-members (partial get-org-members-api cfg)
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
:add-profile-to-org (partial add-profile-to-org-api cfg)
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
:get-org-permissions (partial get-org-permissions-api cfg)
:get-org-sso-by-team (partial get-org-sso-by-team-api cfg)
:delete-team (partial delete-team-api cfg)
:remove-team-from-org (partial remove-team-from-org-api cfg)
:get-subscription (partial get-subscription-api cfg)
:get-subscription-warning (partial get-subscription-warning-api cfg)
:connectivity (partial get-connectivity-api cfg)
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
@ -382,25 +498,49 @@
;; UTILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn sso-session-authorized?
"Fetches the org-SSO config for the given team and checks whether
the HTTP request has a valid session entry for it. Returns a map
with :authorized and :sso keys."
[cfg team-id request]
(let [session (session/get-session request) sso (call cfg :get-org-sso-by-team {:team-id team-id})]
(if-not (:active sso)
{:authorized true :sso sso}
(if (or (:issuer sso) (:base-url sso))
(let [props (:props session)
sso-map (get props :sso {})
organization-id (:organization-id sso)
exp (get sso-map organization-id)
now (ct/now)
authorized (and (ct/inst? exp)
(ct/is-after? exp now))]
{:authorized authorized :sso sso})
{:authorized false :sso sso}))))
(defn add-nitrate-licence-to-profile
"Enriches a profile map with subscription information from Nitrate.
Adds a :subscription field containing the user's license details.
Returns the original profile unchanged if the request fails."
Returns the original profile unchanged if the request fails for a reason
other than Nitrate being unreachable. When Nitrate is unreachable the
`:nitrate-unavailable` exception propagates so the request is rejected."
[cfg profile]
(try
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
(assoc profile :subscription subscription))
(catch Throwable cause
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))
(if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause)
(do
(l/error :hint "failed to get nitrate licence"
:profile-id (:id profile)
:cause cause)
profile)))))
(defn add-org-info-to-team
"Enriches a team map with organization information from Nitrate.
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
Returns the original team unchanged if the request fails or org data is nil."
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]
(try
(let [params (assoc (or params {}) :team-id (:id team))
@ -413,10 +553,13 @@
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
team))
(catch Throwable cause
(l/error :hint "failed to get team organization info"
:team-id (:id team)
:cause cause)
team)))
(if (= :nitrate-unavailable (-> cause ex-data :type))
(throw cause)
(do
(l/error :hint "failed to get team organization info"
:team-id (:id team)
:cause cause)
team)))))
(defn set-team-organization
"Associates a team with an organization in Nitrate.
@ -434,7 +577,3 @@
:context {:team-id (:id team)
:organization-id (:organization-id params)}))
team))

View File

@ -43,7 +43,6 @@
io.lettuce.core.ScriptOutputType
io.lettuce.core.SetArgs
io.netty.channel.nio.NioEventLoopGroup
io.netty.util.concurrent.EventExecutorGroup
io.netty.util.HashedWheelTimer
io.netty.util.Timer
java.lang.AutoCloseable
@ -527,7 +526,6 @@
(def ^:private schema:client-params
[:map {:title "redis-params"}
::wrk/netty-io-executor
::wrk/netty-executor
[::uri ::sm/uri]
[::timeout ::ct/duration]])
@ -539,7 +537,7 @@
(check-client-params params))
(defmethod ig/init-key ::client
[_ {:keys [::uri ::wrk/netty-io-executor ::wrk/netty-executor] :as params}]
[_ {:keys [::uri ::wrk/netty-io-executor] :as params}]
(l/inf :hint "initialize redis client" :uri (str uri))
@ -547,7 +545,6 @@
cache (atom {})
resources (.. (DefaultClientResources/builder)
(eventExecutorGroup ^EventExecutorGroup netty-executor)
;; We provide lettuce with a shared event loop
;; group instance instead of letting lettuce to

View File

@ -27,8 +27,10 @@
[app.main :as-alias main]
[app.metrics :as mtx]
[app.msgbus :as-alias mbus]
[app.nitrate :as nitrate]
[app.redis :as rds]
[app.rpc.climit :as climit]
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as cond]
[app.rpc.doc :as doc]
[app.rpc.helpers :as rph]
@ -36,6 +38,7 @@
[app.rpc.rlimit :as rlimit]
[app.setup :as-alias setup]
[app.storage :as-alias sto]
[app.util.cache :as cache]
[app.util.inet :as inet]
[app.util.services :as sv]
[clojure.spec.alpha :as s]
@ -208,6 +211,74 @@
::sm/explain (explain params)))))))
f))
(defonce ^:private org-sso-auth-cache
(cache/create :expire "15m" :max-size 1024))
(defn invalidate-org-sso-cache-by-org!
"Invalidates all org-SSO authorization cache entries for the given organization-id."
[organization-id]
(cache/invalidate-if org-sso-auth-cache #(= (:organization-id %) organization-id)))
(defn- wrap-nitrate-sso
"Enforce Nitrate organization SSO authentication for RPC handlers.
Resolves the team context from request params using priority order:
1. Explicit :team-id param
2. Explicit :project-id param lookup project.team_id
3. Explicit :file-id param lookup file's team via join
4. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file)
Once team-id is resolved, checks if the user is authorized within that org's SSO
session using nitrate/sso-session-authorized?. Results are cached by [profile-id cache-ref]
for 15 minutes to avoid repeated lookups.
Only activates when:
- Nitrate flag is enabled
- Endpoint requires authentication (::auth true by default)
- Endpoint is not marked with ::nitrate/org-sso false
Raises :nitrate-sso-required error if user is not authorized in the org."
[_ f mdata]
(if (and (contains? cf/flags :nitrate)
(::auth mdata true) ;; only for endpoints that needs auth
(::nitrate/sso mdata true))
(fn [cfg params]
;; Resolve team/project/file from explicit keys or from :id via metadata
(let [id-type (::id-type mdata)
id (uuid/coerce (:id params))
team-id (or (uuid/coerce (:team-id params))
(when (= id-type :team) id))
project-id (or (uuid/coerce (:project-id params))
(when (= id-type :project) id))
file-id (or (uuid/coerce (:file-id params))
(when (= id-type :file) id))]
(if (or team-id project-id file-id)
(let [cache-ref (or team-id project-id file-id)
profile-id (::profile-id params)
cache-key [profile-id cache-ref]
cached (cache/get org-sso-auth-cache cache-key)
result (if (some? cached)
cached
(let [team-id (or team-id
(when project-id
(:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]})))
(:id (teams/get-team-for-file cfg file-id)))
request (-> (meta params) (get ::http/request))
{:keys [authorized sso]} (nitrate/sso-session-authorized? cfg team-id request)
entry {:authorized authorized
:organization-id (:organization-id sso)}]
(when authorized
(cache/get org-sso-auth-cache cache-key (constantly entry)))
entry))]
(if (:authorized result)
(f cfg params)
(ex/raise :type :authentication
:code :nitrate-sso-required
:hint "organization SSO authentication required")))
(f cfg params))))
f))
(defn- wrap
[cfg f mdata]
(as-> f $
@ -220,7 +291,8 @@
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(wrap-authentication cfg $ mdata)
(wrap-nitrate-sso cfg $ mdata)))
(defn- wrap-management
[cfg f mdata]
@ -232,7 +304,10 @@
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(wrap-authentication cfg $ mdata)
(wrap-nitrate-sso cfg $ mdata)))
(defn- process-method
[cfg wrap-fn [f mdata]]

View File

@ -320,7 +320,7 @@
(try
(let [storage (sto/resolve cfg)
input (media/download-image cfg uri)
input (media/run {:cmd :info :input input})
input (media/run cfg {:cmd :info :input input})
hash (sto/calculate-hash (:path input))
content (-> (sto/content (:path input) (:size input))
(sto/wrap-with-hash hash))
@ -545,6 +545,12 @@
::audit/context {:action "email-verification"}
::audit/profile-id (:id profile)})))))
;; When email verification is disabled and an inactive profile already
;; exists, reject the registration — the email is already taken.
(not (contains? cf/flags :email-verification))
(ex/raise :type :validation
:code :email-already-exists)
:else
(let [elapsed? (elapsed-verify-threshold? profile)
reports? (eml/has-reports? conn (:email profile))

View File

@ -165,6 +165,7 @@
(sv/defmethod ::get-file
"Retrieve a file by its ID. Only authenticated users."
{::doc/added "1.17"
::rpc/id-type :file
::cond/get-object #(get-minimal-file-with-perms %1 %2)
::cond/key-fn get-file-etag
::sm/params schema:get-file
@ -601,6 +602,7 @@
(sv/defmethod ::get-file-summary
"Retrieve a file summary by its ID. Only authenticated users."
{::doc/added "1.20"
::rpc/id-type :file
::sm/params schema:get-file-summary}
[cfg {:keys [::rpc/profile-id id] :as params}]
(check-read-permissions! cfg profile-id id)
@ -669,6 +671,7 @@
outbound library reference counts. Cheap alternative to `get-file`
when only metrics are needed."
{::doc/added "2.17"
::rpc/id-type :file
::sm/params schema:get-file-stats
::sm/result schema:get-file-stats-result
::db/transaction true}
@ -842,6 +845,7 @@
(sv/defmethod ::rename-file
{::doc/added "1.17"
::rpc/id-type :file
::webhooks/event? true
::sm/webhook
@ -1001,6 +1005,7 @@
(sv/defmethod ::set-file-shared
{::doc/added "1.17"
::rpc/id-type :file
::webhooks/event? true
::sm/params schema:set-file-shared}
[cfg {:keys [::rpc/profile-id] :as params}]
@ -1056,6 +1061,7 @@
(sv/defmethod ::delete-file
{::doc/added "1.17"
::rpc/id-type :file
::webhooks/event? true
::sm/params schema:delete-file}
[cfg {:keys [::rpc/profile-id] :as params}]

View File

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

View File

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

View File

@ -130,7 +130,8 @@
;; database.
(sv/defmethod ::update-file
{::climit/id [[:update-file/by-profile ::rpc/profile-id]
{::rpc/id-type :file
::climit/id [[:update-file/by-profile ::rpc/profile-id]
[:update-file/global]]
::webhooks/event? true

View File

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

View File

@ -123,11 +123,10 @@
:bucket "file-media-object"}))
(defn- process-thumb-image
[info]
(let [thumb (-> thumbnail-options
(assoc :cmd :generic-thumbnail)
(assoc :input info)
(media/run))
[cfg info]
(let [thumb (media/run cfg (assoc thumbnail-options
:cmd :generic-thumbnail
:input info))
hash (sto/calculate-hash (:data thumb))
data (-> (sto/content (:data thumb) (:size thumb))
(sto/wrap-with-hash hash))]
@ -138,12 +137,12 @@
:bucket "file-media-object"}))
(defn- process-image
[content]
(let [info (media/run {:cmd :info :input content})]
[cfg content]
(let [info (media/run cfg {:cmd :info :input content})]
(cond-> info
(and (not (svg-image? info))
(big-enough-for-thumbnail? info))
(assoc ::thumb (process-thumb-image info))
(assoc ::thumb (process-thumb-image cfg info))
:always
(assoc ::image (process-main-image info)))))
@ -170,7 +169,7 @@
:path (str (:path content))
:origin origin)
(let [result (process-image content)
(let [result (process-image cfg content)
image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))

View File

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

View File

@ -89,6 +89,12 @@
email)]
email))
(defn- with-nitrate-licence
[profile cfg]
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
;; --- QUERY: Get profile (own)
@ -106,12 +112,12 @@
(let [profile (-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))]
(if (contains? cf/flags :nitrate)
(nitrate/add-nitrate-licence-to-profile cfg profile)
profile))
(with-nitrate-licence profile cfg))
(catch Throwable _
{:id uuid/zero :fullname "Anonymous User"})))
(catch Throwable cause
(if (= :not-found (-> cause ex-data :type))
{:id uuid/zero :fullname "Anonymous User"}
(throw cause)))))
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
@ -135,7 +141,7 @@
::sm/params schema:update-profile
::sm/result schema:profile
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id fullname lang theme] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}]
;; NOTE: we need to retrieve the profile independently if we use
;; it or not for explicit locking and avoid concurrent updates of
;; the same row/object.
@ -156,6 +162,7 @@
(-> profile
(strip-private-attrs)
(d/without-nils)
(with-nitrate-licence cfg)
(rph/with-meta {::audit/props (audit/profile->props profile)}))))
@ -291,14 +298,14 @@
:file-mtype (:mtype file)}}))))
(defn- generate-thumbnail
[_ input]
(let [input (media/run {:cmd :info :input input})
thumb (media/run {:cmd :profile-thumbnail
:format :jpeg
:quality 85
:width 256
:height 256
:input input})
[cfg input]
(let [input (media/run cfg {:cmd :info :input input})
thumb (media/run cfg {:cmd :profile-thumbnail
:format :jpeg
:quality 85
:width 256
:height 256
:input input})
hash (sto/calculate-hash (:data thumb))
content (-> (sto/content (:data thumb) (:size thumb))
(sto/wrap-with-hash hash))]
@ -483,8 +490,17 @@
{:deleted-at deleted-at}
{:id profile-id})
;; Api call to nitrate
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
;; Delete owned organizations on the fly (no grace period).
;; Nitrate iterates the user's owned orgs and, per org, calls
;; Penpot back through two paths: ::notify-user-organizations-deletion
;; (during delete-owned-orgs) and ::notify-organization-deletion.
;; Both preserve org teams unchanged and only prefix or delete
;; imported "Your Penpot" teams according to whether they still have files.
;; Let Nitrate clean up the data associated with the deleted Penpot user:
;; owned organizations, remaining memberships, and subscription cancellation.
(when (contains? cf/flags :nitrate)
(nitrate/call cfg :cleanup-deleted-penpot-user
{:profile-id profile-id}))
;; Schedule cascade deletion to a worker
(wrk/submit! {::db/conn conn
@ -493,7 +509,6 @@
:deleted-at deleted-at
:id profile-id}})
(-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg)))))
@ -520,6 +535,32 @@
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
{:editors editors}))
;; --- QUERY: Owned Organizations Summary (for delete-account modal)
(def ^:private schema:owned-organization-summary
[:map
[:id ::sm/uuid]
[:name ::sm/text]
[:slug ::sm/text]
[:team-count ::sm/int]
[:member-count ::sm/int]
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
[:logo-id {:optional true} [:maybe ::sm/uuid]]
[:custom-photo {:optional true} [:maybe ::sm/text]]])
(def ^:private schema:get-owned-organizations-summary-result
[:vector schema:owned-organization-summary])
(sv/defmethod ::get-owned-organizations-summary
"List organizations owned by the current profile with team and member counts.
Used by the delete-account modal to warn the user about cascading deletion."
{::doc/added "2.18"
::sm/result schema:get-owned-organizations-summary-result}
[cfg {:keys [::rpc/profile-id]}]
(if (contains? cf/flags :nitrate)
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
[]))
;; --- HELPERS
(def sql:owned-teams

View File

@ -157,6 +157,7 @@
(sv/defmethod ::get-project
{::doc/added "1.18"
::rpc/id-type :project
::sm/params schema:get-project}
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
(dm/with-open [conn (db/open pool)]
@ -223,6 +224,7 @@
(sv/defmethod ::update-project-pin
{::doc/added "1.18"
::rpc/id-type :project
::sm/params schema:update-project-pin
::webhooks/batch-timeout (ct/duration "5s")
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
@ -244,6 +246,7 @@
(sv/defmethod ::rename-project
{::doc/added "1.18"
::rpc/id-type :project
::sm/params schema:rename-project
::webhooks/event? true
::db/transaction true}
@ -286,6 +289,7 @@
(sv/defmethod ::delete-project
{::doc/added "1.18"
::rpc/id-type :project
::sm/params schema:delete-project
::webhooks/event? true
::db/transaction true}

View File

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

View File

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

View File

@ -185,7 +185,10 @@
registration-disabled? (not (contains? cf/flags :registration))
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
membership (when org-invitation?
;; Membership only makes sense for a logged-in profile; querying it for
;; an anonymous recipient would call nitrate with a nil profile-id and
;; mask the clean :invalid-token response with a generic error.
membership (when (and profile org-invitation?)
(nitrate/call cfg :get-org-membership {:profile-id profile-id
:organization-id organization-id}))]

View File

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

View File

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

View File

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

View File

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

View File

@ -57,9 +57,9 @@
(if (fs/exists? path)
(io/input-stream path)
(let [resp (http/req cfg
{:method :get :uri (:file-uri template)}
{:response-type :input-stream :sync? true})]
(let [resp (http/req-with-redirects cfg
{:method :get :uri (:file-uri template)}
{:response-type :input-stream :sync? true})]
(when-not (= 200 (:status resp))
(ex/raise :type :internal
:code :unexpected-status-code

View File

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

View File

@ -1,38 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.svgo
"A SVG Optimizer service"
(:require
[app.common.logging :as l]
[app.util.shell :as shell]
[datoteka.fs :as fs]
[promesa.exec.semaphore :as ps]))
(def ^:dynamic *semaphore*
"A dynamic variable that can optionally contain a traffic light to
appropriately delimit the use of resources, managed externally."
nil)
(set! *warn-on-reflection* true)
(defn optimize
[system data]
(try
(some-> *semaphore* ps/acquire!)
(let [script (fs/join fs/*cwd* "scripts/svgo-cli.js")
cmd ["node" (str script)]
result (shell/exec! system
:cmd cmd
:in data)]
(if (= (:exit result) 0)
(:out result)
(do
(l/raw! :warn (str "Error on optimizing svg, returning svg as-is." (:err result)))
data)))
(finally
(some-> *semaphore* ps/release!))))

View File

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

View File

@ -8,17 +8,32 @@
"A penpot specific, modern api for executing external (shell)
subprocesses"
(:require
[app.common.exceptions :as ex]
[app.worker :as-alias wrk]
[datoteka.io :as io]
[promesa.exec :as px])
(:import
java.io.InputStream
java.io.OutputStream
java.util.concurrent.TimeUnit
java.util.List
org.apache.commons.io.IOUtils))
(set! *warn-on-reflection* true)
(defn- prlimit-cmd
"Build a prlimit command prefix from a resource limits map.
Returns nil if limits is nil/empty."
[limits]
(when (seq limits)
(let [prefix (cond-> ["prlimit"]
(:mem limits)
(conj (str "--as=" (* (long (:mem limits)) 1024 1024)))
(:cpu limits)
(conj (str "--cpu=" (long (:cpu limits)))))]
(conj prefix "--"))))
(defn- read-as-bytes
[in]
(with-open [^InputStream input (io/input-stream in)]
@ -39,17 +54,22 @@
[penv k v]
(.put ^java.util.Map penv
^String k
^String v))
^String v)
penv)
(defn exec!
[system & {:keys [cmd in out-enc in-enc env]
[system & {:keys [cmd in out-enc in-enc env prlimit timeout]
:or {out-enc "UTF-8"
in-enc "UTF-8"}}]
(assert (vector? cmd) "a command parameter should be a vector")
(assert (every? string? cmd) "the command should be a vector of strings")
(let [executor (::wrk/executor system)
builder (ProcessBuilder. ^List cmd)
_ (assert (some? executor) "executor is required, check ::wrk/executor")
full-cmd (cond->> cmd
(seq prlimit)
(into (prlimit-cmd prlimit)))
builder (ProcessBuilder. ^List full-cmd)
env-map (.environment ^ProcessBuilder builder)
_ (reduce-kv set-env env-map env)
process (.start builder)]
@ -63,9 +83,22 @@
(with-open [stdout (.getInputStream ^Process process)
stderr (.getErrorStream ^Process process)]
(let [out (px/submit! executor (fn [] (read-with-enc stdout out-enc)))
err (px/submit! executor (fn [] (read-as-string stderr)))
ext (.waitFor ^Process process)]
(let [out (px/submit! executor (fn [] (try (read-with-enc stdout out-enc)
(catch java.io.IOException _ ""))))
err (px/submit! executor (fn [] (try (read-as-string stderr)
(catch java.io.IOException _ ""))))
ext (if timeout
(let [completed (.waitFor ^Process process (long timeout) TimeUnit/SECONDS)]
(if completed
(.exitValue ^Process process)
(do
(.destroyForcibly ^Process process)
(ex/raise :type :internal
:code :process-timeout
:hint (str "process timed out after " timeout " seconds")
:cmd cmd
:timeout timeout))))
(.waitFor ^Process process))]
{:exit ext
:out @out
:err @err}))))

View File

@ -15,9 +15,7 @@
[promesa.exec :as px])
(:import
io.netty.channel.nio.NioEventLoopGroup
io.netty.util.concurrent.DefaultEventExecutorGroup
java.util.concurrent.ExecutorService
java.util.concurrent.ThreadFactory
java.util.concurrent.TimeUnit))
(set! *warn-on-reflection* true)
@ -36,13 +34,6 @@
{:title "executor"
:description "Instance of NioEventLoopGroup"}})
(sm/register!
{:type ::wrk/netty-executor
:pred #(instance? DefaultEventExecutorGroup %)
:type-properties
{:title "executor"
:description "Instance of DefaultEventExecutorGroup"}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IO Executor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -58,7 +49,7 @@
nthreads (or threads (mth/round (/ (px/get-available-processors) 2)))
nthreads (max 2 nthreads)]
(l/inf :hint "start netty io executor" :threads nthreads)
(NioEventLoopGroup. (int nthreads) ^ThreadFactory factory)))
(NioEventLoopGroup. (int nthreads) ^java.util.concurrent.ThreadFactory factory)))
(defmethod ig/halt-key! ::wrk/netty-io-executor
[_ instance]
@ -68,22 +59,15 @@
TimeUnit/MILLISECONDS)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IO Offload Executor
;; Executor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/assert-key ::wrk/netty-executor
[_ {:keys [threads]}]
(assert (or (nil? threads) (int? threads))
"expected valid threads value, revisit PENPOT_EXEC_THREADS environment variable"))
(defmethod ig/init-key ::wrk/executor
[_ _]
(let [factory (px/thread-factory :prefix "penpot/exec/")]
(l/inf :hint "start cached executor")
(px/cached-executor :factory factory)))
(defmethod ig/init-key ::wrk/netty-executor
[_ {:keys [threads]}]
(let [factory (px/thread-factory :prefix "penpot/exec/")
nthreads (or threads (mth/round (/ (px/get-available-processors) 2)))
nthreads (max 2 nthreads)]
(l/inf :hint "start default executor" :threads nthreads)
(DefaultEventExecutorGroup. (int nthreads) ^ThreadFactory factory)))
(defmethod ig/halt-key! ::wrk/netty-executor
(defmethod ig/halt-key! ::wrk/executor
[_ instance]
(px/shutdown! instance))

View File

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

View File

@ -0,0 +1,36 @@
;; 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

@ -0,0 +1,126 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.media-test
(:require
[app.common.exceptions :as ex]
[app.media :as media]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
(t/use-fixtures :once th/state-init)
(t/deftest info-jpeg
(t/testing "info on valid JPEG returns dimensions and mime type"
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/jpeg"}})]
(t/is (pos? (:width info)))
(t/is (pos? (:height info)))
(t/is (= "image/jpeg" (:mtype info)))
(t/is (pos? (:size info)))
(t/is (some? (:ts info))))))
(t/deftest info-png
(t/testing "info on valid PNG returns dimensions and mime type"
(let [path (th/tempfile "backend_tests/test_files/sample.png")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/png"}})]
(t/is (pos? (:width info)))
(t/is (pos? (:height info)))
(t/is (= "image/png" (:mtype info))))))
(t/deftest info-webp
(t/testing "info on valid WebP returns dimensions and mime type"
(let [path (th/tempfile "backend_tests/test_files/sample.webp")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/webp"}})]
(t/is (pos? (:width info)))
(t/is (pos? (:height info)))
(t/is (= "image/webp" (:mtype info))))))
(t/deftest info-svg
(t/testing "info on valid SVG returns dimensions from viewBox"
(let [path (th/tempfile "backend_tests/test_files/sample1.svg")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/svg+xml"}})]
(t/is (pos? (:width info)))
(t/is (pos? (:height info))))))
(t/deftest info-invalid-image
(t/testing "info on invalid image raises error"
(let [path (fs/create-tempfile :prefix "penpot-test-" :suffix ".jpg")]
;; Write garbage data
(spit (str path) "not an image")
(try
(media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/jpeg"}})
(t/is false "should have thrown")
(catch Exception e
(let [data (ex-data e)]
;; Could be validation or imagemagick-error depending on what magick does
(t/is (contains? #{:validation :internal} (:type data)))))
(finally
(fs/delete path))))))
(t/deftest generic-thumbnail
(t/testing "generic-thumbnail produces a file of expected format"
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/jpeg"}})
thumb (media/run th/*system* {:cmd :generic-thumbnail
:input info
:format :jpeg
:quality 80
:width 200
:height 200})]
(t/is (some? (:data thumb)))
(t/is (pos? (:size thumb)))
(t/is (= :jpeg (:format thumb)))
(t/is (= "image/jpeg" (:mtype thumb)))
;; Verify the thumbnail file exists
(t/is (fs/exists? (:data thumb))))))
(t/deftest profile-thumbnail
(t/testing "profile-thumbnail produces a center-cropped file"
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/jpeg"}})
thumb (media/run th/*system* {:cmd :profile-thumbnail
:input info
:format :jpeg
:quality 85
:width 128
:height 128})]
(t/is (some? (:data thumb)))
(t/is (pos? (:size thumb)))
(t/is (= :jpeg (:format thumb)))
(t/is (= "image/jpeg" (:mtype thumb)))
;; Verify the thumbnail file exists
(t/is (fs/exists? (:data thumb))))))
(t/deftest generic-thumbnail-webp
(t/testing "generic-thumbnail can produce WebP format"
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
info (media/run th/*system* {:cmd :info
:input {:path path
:mtype "image/jpeg"}})
thumb (media/run th/*system* {:cmd :generic-thumbnail
:input info
:format :webp
:quality 80
:width 200
:height 200})]
(t/is (= :webp (:format thumb)))
(t/is (= "image/webp" (:mtype thumb))))))

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@
[app.db :as db]
[app.email.blacklist :as email.blacklist]
[app.email.whitelist :as email.whitelist]
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.tokens :as tokens]
@ -90,17 +91,26 @@
(t/is (not (contains? result :password))))))
(t/testing "update profile"
(let [data (assoc profile
::th/type :update-profile
::rpc/profile-id (:id profile)
:fullname "Full Name"
:lang "en"
:theme "dark")
out (th/command! data)]
(with-redefs [app.config/flags #{:nitrate}]
(with-redefs [nitrate/add-nitrate-licence-to-profile
(fn [_ profile]
(assoc profile :subscription {:plan :pro}))]
(let [data (assoc profile
::th/type :update-profile
::rpc/profile-id (:id profile)
:fullname "Full Name"
:lang "en"
:theme "dark")
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (map? (:result out)))))
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (map? (:result out)))
(t/is (= "Full Name" (get-in out [:result :fullname])))
(t/is (= "en" (get-in out [:result :lang])))
(t/is (= "dark" (get-in out [:result :theme])))
(t/is (= {:plan :pro}
(:subscription (:result out))))))))
(t/testing "query profile after update"
(let [data {::th/type :get-profile
@ -526,6 +536,65 @@
(t/is (nil? (:error out)))
(t/is (= 0 (:call-count @mock))))))))
(t/deftest prepare-register-and-register-profile-disable-email-verification
;; When disable-email-verification is set and the profile is inactive
;; (e.g. created before the flag was set), re-registering should be
;; rejected with :email-already-exists.
(with-mocks [mock {:target 'app.email/send! :return nil}]
(with-redefs [app.config/flags #{:registration :login-with-password}]
(let [current-token (atom nil)]
;; PREPARE REGISTER: first attempt (no profile exists yet)
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
(reset! current-token token))
;; DO REGISTRATION: creates active profile (email-verification disabled)
(let [data {::th/type :register-profile
:token @current-token}
out (th/command! data)
mdata (-> out :result meta)]
(t/is (nil? (:error out)))
;; No verification email sent
(t/is (= 0 (:call-count @mock)))
;; Session is minted
(t/is (seq (:app.rpc/response-transform-fns mdata))))
;; Force the profile back to inactive to simulate the case where it was
;; created before disable-email-verification was set
(th/db-update! :profile
{:is-active false}
{:email "hello@example.com"})
(th/reset-mock! mock)
;; PREPARE REGISTER: second attempt (inactive profile exists)
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
(reset! current-token token))
;; DO REGISTRATION: second attempt should be rejected
(let [data {::th/type :register-profile
:token @current-token}
out (th/command! data)
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :email-already-exists))
;; No email sent, profile remains inactive
(t/is (= 0 (:call-count @mock)))
(let [profile (th/db-get :profile {:email "hello@example.com"})]
(t/is (false? (:is-active profile)))))))))
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
;; With email-verification ENABLED (the default), a brand-new
;; profile created via the invitation flow is NOT active yet, so

View File

@ -0,0 +1,107 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns backend-tests.shell-test
(:require
[app.common.exceptions :as ex]
[app.util.shell :as shell]
[clojure.string :as str]
[clojure.test :as t]))
(t/deftest exec-normal-completes
(t/testing "normal process completes within timeout"
(let [result (shell/exec! {}
:cmd ["echo" "hello"]
:timeout 10)]
(t/is (= 0 (:exit result)))
(t/is (str/includes? (:out result) "hello")))))
(t/deftest exec-captures-stderr
(t/testing "stderr is captured separately"
(let [result (shell/exec! {}
:cmd ["bash" "-c" "echo out; echo err >&2"]
:timeout 10)]
(t/is (= 0 (:exit result)))
(t/is (str/includes? (:out result) "out"))
(t/is (str/includes? (:err result) "err")))))
(t/deftest exec-non-zero-exit
(t/testing "non-zero exit code is captured"
(let [result (shell/exec! {}
:cmd ["bash" "-c" "exit 42"]
:timeout 10)]
(t/is (= 42 (:exit result))))))
(t/deftest exec-with-env
(t/testing "environment variables are passed to the process"
(let [result (shell/exec! {}
:cmd ["bash" "-c" "echo $MY_VAR"]
:env {"MY_VAR" "test-value"}
:timeout 10)]
(t/is (= 0 (:exit result)))
(t/is (str/includes? (:out result) "test-value")))))
(t/deftest exec-with-input
(t/testing "stdin input is passed to the process"
(let [result (shell/exec! {}
:cmd ["cat"]
:in "hello from stdin"
:timeout 10)]
(t/is (= 0 (:exit result)))
(t/is (str/includes? (:out result) "hello from stdin")))))
(t/deftest exec-timeout-kills-process
(t/testing "process that exceeds timeout is killed and raises exception"
(let [start (System/currentTimeMillis)]
(try
(shell/exec! {}
:cmd ["sleep" "60"]
:timeout 1)
(t/is false "should have thrown")
(catch Exception e
(let [elapsed (- (System/currentTimeMillis) start)
data (ex-data e)]
;; Should complete quickly due to timeout, not wait 60s
(t/is (< elapsed 10000) "process should be killed within ~1 second")
(t/is (= :internal (:type data)))
(t/is (= :process-timeout (:code data)))
(t/is (= 1 (:timeout data)))))))))
(t/deftest exec-no-timeout-waits
(t/testing "without timeout, process runs to completion"
(let [result (shell/exec! {}
:cmd ["sleep" "0.1"]
:timeout nil)]
(t/is (= 0 (:exit result))))))
(t/deftest exec-prlimit-normal
(t/testing "normal process completes within prlimit"
(let [result (shell/exec! {}
:cmd ["echo" "hello"]
:prlimit {:mem 256 :cpu 10}
:timeout 10)]
(t/is (= 0 (:exit result)))
(t/is (str/includes? (:out result) "hello")))))
(t/deftest exec-prlimit-cpu
(t/testing "process exceeding CPU limit is killed"
(let [result (shell/exec! {}
:cmd ["bash" "-c" "while true; do :; done"]
:prlimit {:cpu 2}
:timeout 10)]
(t/is (not= 0 (:exit result))))))
(t/deftest exec-prlimit-memory
(t/testing "process exceeding memory limit is killed"
;; Use python3 to allocate more memory than the limit allows.
;; This test requires python3 to be available in the environment.
(let [result (shell/exec! {}
:cmd ["python3" "-c"
"import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"]
:prlimit {:mem 256}
:timeout 10)]
;; Should fail because 600 MiB > 256 MiB limit
(t/is (not= 0 (:exit result))))))

View File

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

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.5.3+sha512.7ac1c919341c213a34dc0d02afb7143c5c26ac26ee8c4782deea821b8ac64d2134a081fd8941dae6e29bbb48f58dfc2b7fbceeccc07cb2f09d219d342a4969ed",
"packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620",
"type": "module",
"repository": {
"type": "git",
@ -30,6 +30,7 @@
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test:js": "pnpm run build:test && node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js",
"test:jvm": "clojure -M:dev:test"
}
}

View File

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

View File

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

View File

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

View File

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

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