mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +00:00
Merge remote-tracking branch 'origin/staging'
This commit is contained in:
commit
1b00ca8cb9
133
.devenv/README.md
Normal file
133
.devenv/README.md
Normal 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).
|
||||||
204
.devenv/scripts/merge-mcp-config.py
Executable file
204
.devenv/scripts/merge-mcp-config.py
Executable 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:]))
|
||||||
8
.devenv/shared/claude-code.json
Normal file
8
.devenv/shared/claude-code.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.devenv/shared/codex.toml
Normal file
8
.devenv/shared/codex.toml
Normal 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"]
|
||||||
9
.devenv/shared/opencode.json
Normal file
9
.devenv/shared/opencode.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
.devenv/shared/vscode.json
Normal file
9
.devenv/shared/vscode.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest", "--cdp-endpoint=http://127.0.0.1:9222"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
.devenv/templates/claude-code.json
Normal file
12
.devenv/templates/claude-code.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.devenv/templates/codex.toml
Normal file
10
.devenv/templates/codex.toml
Normal 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"
|
||||||
14
.devenv/templates/opencode.json
Normal file
14
.devenv/templates/opencode.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
.devenv/templates/vscode.json
Normal file
12
.devenv/templates/vscode.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
.github/workflows/build-main-staging.yml
vendored
22
.github/workflows/build-main-staging.yml
vendored
@ -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"
|
|
||||||
133
.github/workflows/tests-plugin-api-suite.yml
vendored
Normal file
133
.github/workflows/tests-plugin-api-suite.yml
vendored
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
name: "CI: Plugin API Test Suite"
|
||||||
|
|
||||||
|
# Runs the Plugin API Test Suite (it exercises the real Penpot Plugin API, so it
|
||||||
|
# needs a running frontend + the plugin runtime). Two jobs:
|
||||||
|
#
|
||||||
|
# - api-test-suite-mocked (pull_request / push): the per-PR gate. Serves the
|
||||||
|
# prebuilt frontend bundle and intercepts every backend RPC with Playwright
|
||||||
|
# (MOCK_BACKEND=1). No backend / no login. Validates the frontend Plugin API
|
||||||
|
# binding + in-memory store; backend-result-dependent tests are skipped via the
|
||||||
|
# `skipIfMocked` tag. See plugins/apps/plugin-api-test-suite/README.md.
|
||||||
|
#
|
||||||
|
# - api-test-suite-live (workflow_dispatch): true end-to-end against a LIVE
|
||||||
|
# instance. Point PENPOT_BASE_URL at a reachable instance and provide login
|
||||||
|
# credentials via repo secrets. Manual because the CI runner has no Docker to
|
||||||
|
# stand up a full stack.
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
base_url:
|
||||||
|
description: "Penpot base URL (e.g. https://localhost:3449)"
|
||||||
|
required: false
|
||||||
|
default: "https://localhost:3449"
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'plugins/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'common/**'
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- staging
|
||||||
|
paths:
|
||||||
|
- 'plugins/**'
|
||||||
|
- 'frontend/src/app/plugins/**'
|
||||||
|
- 'common/**'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api-test-suite-mocked:
|
||||||
|
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
|
||||||
|
name: "Run Plugin API Test Suite (mocked)"
|
||||||
|
runs-on: penpot-runner-02
|
||||||
|
container:
|
||||||
|
image: penpotapp/devenv:latest
|
||||||
|
volumes:
|
||||||
|
- /var/cache/github-runner/m2:/root/.m2
|
||||||
|
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# Mocked mode serves the prebuilt bundle from frontend/resources/public.
|
||||||
|
- name: Build frontend bundle
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: ./scripts/build
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ./plugins
|
||||||
|
run: |
|
||||||
|
corepack enable;
|
||||||
|
corepack install;
|
||||||
|
pnpm install;
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
working-directory: ./plugins
|
||||||
|
run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Generate API surface
|
||||||
|
working-directory: ./plugins
|
||||||
|
run: pnpm --filter plugin-api-test-suite run gen:api
|
||||||
|
|
||||||
|
- name: Run API test suite (mocked)
|
||||||
|
working-directory: ./plugins
|
||||||
|
env:
|
||||||
|
MOCK_BACKEND: "1"
|
||||||
|
run: pnpm --filter plugin-api-test-suite run test:ci
|
||||||
|
|
||||||
|
## The following job will launch the whole suite of tests but we need
|
||||||
|
## to have a full environment in the CI for this to work.
|
||||||
|
|
||||||
|
# api-test-suite-live:
|
||||||
|
# if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
# name: Run Plugin API Test Suite (live)
|
||||||
|
# runs-on: penpot-runner-02
|
||||||
|
# container:
|
||||||
|
# image: penpotapp/devenv:latest
|
||||||
|
#
|
||||||
|
# env:
|
||||||
|
# PENPOT_BASE_URL: ${{ github.event.inputs.base_url }}
|
||||||
|
# E2E_LOGIN_EMAIL: ${{ secrets.E2E_LOGIN_EMAIL }}
|
||||||
|
# E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }}
|
||||||
|
#
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v6
|
||||||
|
#
|
||||||
|
# - name: Setup Node
|
||||||
|
# uses: actions/setup-node@v6
|
||||||
|
# with:
|
||||||
|
# node-version-file: .nvmrc
|
||||||
|
#
|
||||||
|
# - name: Install deps
|
||||||
|
# working-directory: ./plugins
|
||||||
|
# run: |
|
||||||
|
# corepack enable;
|
||||||
|
# corepack install;
|
||||||
|
# pnpm install;
|
||||||
|
#
|
||||||
|
# - name: Install Playwright Chromium
|
||||||
|
# working-directory: ./plugins
|
||||||
|
# run: pnpm --filter plugin-api-test-suite exec playwright install --with-deps chromium
|
||||||
|
#
|
||||||
|
# - name: Generate API surface
|
||||||
|
# working-directory: ./plugins
|
||||||
|
# run: pnpm --filter plugin-api-test-suite run gen:api
|
||||||
|
#
|
||||||
|
# # Note: requires a running Penpot instance reachable at PENPOT_BASE_URL.
|
||||||
|
# - name: Run API test suite
|
||||||
|
# working-directory: ./plugins
|
||||||
|
# run: pnpm --filter plugin-api-test-suite run test:ci
|
||||||
8
.github/workflows/tests-plugins.yml
vendored
8
.github/workflows/tests-plugins.yml
vendored
@ -40,19 +40,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
id: setup-node
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
working-directory: ./plugins
|
working-directory: ./plugins
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
corepack enable;
|
corepack enable;
|
||||||
corepack install;
|
corepack install;
|
||||||
pnpm install;
|
pnpm install -r;
|
||||||
|
|
||||||
- name: Run Lint
|
- name: Run Lint
|
||||||
working-directory: ./plugins
|
working-directory: ./plugins
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -17,6 +17,12 @@ opencode.json
|
|||||||
.opencode/package-lock.json
|
.opencode/package-lock.json
|
||||||
/*.jpg
|
/*.jpg
|
||||||
/*.md
|
/*.md
|
||||||
|
!CHANGES.md
|
||||||
|
!CONTRIBUTING.md
|
||||||
|
!README.md
|
||||||
|
!AGENTS.md
|
||||||
|
!CODE_OF_CONDUCT.md
|
||||||
|
!SECURITY.md
|
||||||
/*.png
|
/*.png
|
||||||
/*.svg
|
/*.svg
|
||||||
/*.sql
|
/*.sql
|
||||||
@ -88,6 +94,10 @@ opencode.json
|
|||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
/.vscode
|
/.vscode
|
||||||
/.idea
|
/.idea
|
||||||
|
*.iml
|
||||||
/.claude
|
/.claude
|
||||||
/.playwright-mcp
|
/.playwright-mcp
|
||||||
|
/.devenv/mcp/
|
||||||
|
/opencode.json
|
||||||
|
/.codex/
|
||||||
/tools/__pycache__
|
/tools/__pycache__
|
||||||
|
|||||||
@ -1,124 +1,39 @@
|
|||||||
---
|
---
|
||||||
name: create-pr
|
name: create-pr
|
||||||
description: Create or update a GitHub PR following Penpot conventions, with a concise engineer-focused description
|
description: Create or update a GitHub PR following Penpot conventions.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pull Request (Create or Update)
|
# Skill: create-pr
|
||||||
|
|
||||||
Create or update a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
|
Create or update a GitHub PR. Read and follow:
|
||||||
|
- `mem:workflow/creating-prs` — title format, description structure, writing principles
|
||||||
|
- `mem:workflow/creating-commits` — commit type emojis
|
||||||
|
|
||||||
## When to Use
|
## When to Use
|
||||||
|
|
||||||
- Opening a new pull request
|
- Creating a new PR from a feature branch
|
||||||
- Updating an existing PR's title or description
|
- Updating an existing PR's title or description to match conventions
|
||||||
- The user asks to create or update a PR
|
|
||||||
- Code changes are ready and committed
|
|
||||||
|
|
||||||
## Conventions
|
## Prerequisites
|
||||||
|
|
||||||
All PR conventions (title format, body structure, writing principles, what NOT to include) are documented in `mem:workflow/creating-prs`. This skill covers the procedure only.
|
- `gh` CLI authenticated (`gh auth status`)
|
||||||
|
|
||||||
## Workflow A: Creating a New PR
|
## Commands
|
||||||
|
|
||||||
### A1. Verify Prerequisites
|
**Create:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git branch --show-current
|
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||||
git log --oneline main..HEAD
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### A2. Check if Branch is Pushed
|
**Update:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BRANCH=$(git branch --show-current)
|
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
|
||||||
echo "Branch is pushed, proceeding with PR creation"
|
|
||||||
else
|
|
||||||
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
|
**Verify:**
|
||||||
|
|
||||||
### A3. Create PR Body
|
|
||||||
|
|
||||||
Write the body to `/tmp/pr-body.md` using the template from `mem:workflow/creating-prs` (Description Body section):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > /tmp/pr-body.md << 'EOF'
|
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||||
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
<one paragraph: the problem or feature, user-facing impact>
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
<root cause or motivation, why this change was necessary>
|
|
||||||
|
|
||||||
## How
|
|
||||||
|
|
||||||
<high-level approach, key technical decisions>
|
|
||||||
EOF
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### A4. Create the PR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Workflow B: Updating an Existing PR
|
|
||||||
|
|
||||||
Use this when a PR already exists and you need to change its title or description (or both).
|
|
||||||
|
|
||||||
### B1. Find the PR Number
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BRANCH=$(git branch --show-current)
|
|
||||||
gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number'
|
|
||||||
```
|
|
||||||
|
|
||||||
If the result is empty, there is no open PR for this branch — use Workflow A instead.
|
|
||||||
If multiple PRs match, pick the first one (typically there should only be one).
|
|
||||||
|
|
||||||
### B2. Determine What to Update
|
|
||||||
|
|
||||||
- **Title only:** use `--title "<new title>"`
|
|
||||||
- **Description (body) only:** write to `/tmp/pr-body.md` and use `--body-file /tmp/pr-body.md`
|
|
||||||
- **Both title and description:** supply both flags
|
|
||||||
|
|
||||||
### B3. Prepare Description (if updating)
|
|
||||||
|
|
||||||
Use the same template from `mem:workflow/creating-prs`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat > /tmp/pr-body.md << 'EOF'
|
|
||||||
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
<one paragraph: the problem or feature, user-facing impact>
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
<root cause or motivation, why this change was necessary>
|
|
||||||
|
|
||||||
## How
|
|
||||||
|
|
||||||
<high-level approach, key technical decisions>
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### B4. Update the PR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh pr edit <NUMBER> --title "<title>" --body-file /tmp/pr-body.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit either flag if only one field is being updated.
|
|
||||||
|
|
||||||
**The LLM has permissions to edit PRs. No push required.**
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
|
|||||||
|
|
||||||
## Worker policy
|
## Worker policy
|
||||||
|
|
||||||
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv-agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
|
||||||
|
|
||||||
## Port layout
|
## Port layout
|
||||||
|
|
||||||
@ -44,13 +44,13 @@ Everything else (frontend dev, backend API, exporter, storybook, REPLs, plugin d
|
|||||||
|
|
||||||
## Tmux + MCP routing
|
## Tmux + MCP routing
|
||||||
|
|
||||||
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv-agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv-agentic` — the conditional windows are only added at session create time.
|
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv --agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv --agentic` — the conditional windows are only added at session create time.
|
||||||
|
|
||||||
MCP plugin routing is same-origin: frontend uses `<public-uri>/mcp/ws`, per-instance nginx proxies to MCP port 4401 in-container. For the plugin↔MCP server wiring (how the browser plugin discovers the URL, the in-memory connection registry, why DB-mediated routing isn't needed), see `mem:mcp/core`.
|
MCP plugin routing is same-origin: frontend uses `<public-uri>/mcp/ws`, per-instance nginx proxies to MCP port 4401 in-container. For the plugin↔MCP server wiring (how the browser plugin discovers the URL, the in-memory connection registry, why DB-mediated routing isn't needed), see `mem:mcp/core`.
|
||||||
|
|
||||||
## Workspace orchestration (ws1+)
|
## Workspace orchestration (ws1+)
|
||||||
|
|
||||||
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv-agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
|
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv --agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
|
||||||
|
|
||||||
`sync-workspace wsN`:
|
`sync-workspace wsN`:
|
||||||
1. `assert-clean-git-state` — refuses on `.git/{rebase-apply,rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD,index.lock}`. No `--sync-force` escape.
|
1. `assert-clean-git-state` — refuses on `.git/{rebase-apply,rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD,index.lock}`. No `--sync-force` escape.
|
||||||
@ -63,7 +63,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
|
|||||||
|
|
||||||
## CLI surface
|
## CLI surface
|
||||||
|
|
||||||
- `run-devenv-agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
|
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
|
||||||
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
|
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
|
||||||
- `run-devenv`: legacy alias, ws0 non-agentic attached.
|
- `run-devenv`: legacy alias, ws0 non-agentic attached.
|
||||||
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
|
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
|
||||||
|
|||||||
@ -15,7 +15,7 @@ automatically pull the identity from the local git config `user.name` and `user.
|
|||||||
|
|
||||||
Body explaining what changed and why.
|
Body explaining what changed and why.
|
||||||
|
|
||||||
Co-authored-by: model-name <model-name@penpot.app>
|
AI-assisted-by: model-name
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commit Type Emojis
|
## Commit Type Emojis
|
||||||
|
|||||||
165
CHANGES.md
165
CHANGES.md
@ -1,5 +1,163 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## 2.17.0 (Unreleased)
|
||||||
|
|
||||||
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
|
- Render prototype viewer with WASM (Skia) engine instead of SVG [#10037](https://github.com/penpot/penpot/issues/10037) (PR: [#10038](https://github.com/penpot/penpot/pull/10038))
|
||||||
|
|
||||||
|
### :sparkles: New features & Enhancements
|
||||||
|
|
||||||
|
- 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))
|
||||||
|
- 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))
|
||||||
|
- Add layer blur effect [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
|
||||||
|
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
|
||||||
|
- Add concurrency limiter for MCP Server Plugin Communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
|
||||||
|
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
||||||
|
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
|
||||||
|
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
|
||||||
|
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
||||||
|
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
|
||||||
|
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
||||||
|
|
||||||
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- 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 `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
|
||||||
|
- 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)
|
||||||
|
- 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)
|
||||||
|
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
|
||||||
|
- 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 copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
||||||
|
- 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 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))
|
||||||
|
- 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))
|
||||||
|
- 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))
|
||||||
|
- Fix inconsistent float precision in typography lineHeight API responses [#3658](https://github.com/penpot/penpot/issues/3658) (PR: [#9973](https://github.com/penpot/penpot/pull/9973))
|
||||||
|
- Fix variant switching preserving stale absolute positions in nested instance overrides [#9749](https://github.com/penpot/penpot/issues/9749) (PR: [#9691](https://github.com/penpot/penpot/pull/9691))
|
||||||
|
- Fix missing top border on first element in shared libraries list [#9910](https://github.com/penpot/penpot/issues/9910) (PR: [#10062](https://github.com/penpot/penpot/pull/10062))
|
||||||
|
- Fix theme not applied correctly in sample plugins [#9957](https://github.com/penpot/penpot/issues/9957) (PR: [#9955](https://github.com/penpot/penpot/pull/9955))
|
||||||
|
- Fix export presets not being saved in view mode inspect panel [#9971](https://github.com/penpot/penpot/issues/9971) (PR: [#9972](https://github.com/penpot/penpot/pull/9972))
|
||||||
|
- Fix cropped outer stroke of rotated board in view mode [#9978](https://github.com/penpot/penpot/issues/9978) (PR: [#9979](https://github.com/penpot/penpot/pull/9979))
|
||||||
|
- Fix re-registration with same email showing verification prompt when email verification is disabled [#9998](https://github.com/penpot/penpot/issues/9998) (PR: [#9999](https://github.com/penpot/penpot/pull/9999))
|
||||||
|
- Fix toggle color library visibility from color picker shortcut button [#10036](https://github.com/penpot/penpot/issues/10036) (PR: [#10129](https://github.com/penpot/penpot/pull/10129))
|
||||||
|
- Improve image rendering performance by making snapshots async [#10045](https://github.com/penpot/penpot/issues/10045) (PR: [#10150](https://github.com/penpot/penpot/pull/10150))
|
||||||
|
- Fix text layers wrapping to two lines after copy-pasting between files until edited [#10052](https://github.com/penpot/penpot/issues/10052) (PR: [#10081](https://github.com/penpot/penpot/pull/10081))
|
||||||
|
- Fix plugin API creating shapes on wrong page and cross-page appendChild silently no-ops [#10078](https://github.com/penpot/penpot/issues/10078) (PR: [#10085](https://github.com/penpot/penpot/pull/10085))
|
||||||
|
- Fix token dropdown triggered with select arrow not replacing previous content [#10104](https://github.com/penpot/penpot/issues/10104) (PR: [#10174](https://github.com/penpot/penpot/pull/10174))
|
||||||
|
- Fix incorrect color count in color libraries dropdown (by @Krishcode264) [#10120](https://github.com/penpot/penpot/issues/10120) (PR: [#10281](https://github.com/penpot/penpot/pull/10281))
|
||||||
|
- Fix color picker eyedropper returning wrong color values on some GPUs [#10135](https://github.com/penpot/penpot/issues/10135) (PR: [#10255](https://github.com/penpot/penpot/pull/10255))
|
||||||
|
- Fix content from previous rendered frame visible in rounded frame transparent areas in viewer WebGL [#10161](https://github.com/penpot/penpot/issues/10161) (PR: [#10191](https://github.com/penpot/penpot/pull/10191))
|
||||||
|
- Fix exported HTML/CSS not correctly rendered [#10214](https://github.com/penpot/penpot/issues/10214) (PR: [#10217](https://github.com/penpot/penpot/pull/10217))
|
||||||
|
- Fix template import failing from dashboard when downloading example files (by @mdbenito) [#10317](https://github.com/penpot/penpot/issues/10317) (PR: [#10308](https://github.com/penpot/penpot/pull/10308))
|
||||||
|
- Emit grid layout telemetry when adding via MCP or plugins [#10307](https://github.com/penpot/penpot/issues/10307) (PR: [#10319](https://github.com/penpot/penpot/pull/10319))
|
||||||
|
- Fix guide hover feedback sticking or stopping after interaction [#10321](https://github.com/penpot/penpot/issues/10321) (PR: [#10333](https://github.com/penpot/penpot/pull/10333))
|
||||||
|
- Fix rulers visible in thumbnail rendering [#10328](https://github.com/penpot/penpot/issues/10328)
|
||||||
|
- Fix page switch blur disappearing early [#10332](https://github.com/penpot/penpot/issues/10332) (PR: [#10337](https://github.com/penpot/penpot/pull/10337))
|
||||||
|
- Fix open overlay board mispositioned with repeated image artifacts in viewer WebGL [#10180](https://github.com/penpot/penpot/issues/10180) (PR: [#10191](https://github.com/penpot/penpot/pull/10191))
|
||||||
|
- Fix color picker preview when browser has non-100% zoom level [#10265](https://github.com/penpot/penpot/issues/10265) (PR: [#10305](https://github.com/penpot/penpot/pull/10305))
|
||||||
|
- Fix negative margins no longer working [#10001](https://github.com/penpot/penpot/issues/10001) (PR: [#10353](https://github.com/penpot/penpot/pull/10353))
|
||||||
|
- Fix undo in properties panel reversing each drag step instead of the entire drag when adjusting numeric inputs [#10066](https://github.com/penpot/penpot/issues/10066) (PR: [#10193](https://github.com/penpot/penpot/pull/10193))
|
||||||
|
- Fix Plugin API reference token creation and token application failing when the target token set is inactive [#10070](https://github.com/penpot/penpot/issues/10070), [#10071](https://github.com/penpot/penpot/issues/10071) (PR: [#10297](https://github.com/penpot/penpot/pull/10297))
|
||||||
|
- Fix Plugin API rejecting numeric token values with a generic validation error [#10073](https://github.com/penpot/penpot/issues/10073) (PR: [#10270](https://github.com/penpot/penpot/pull/10270))
|
||||||
|
- Fix Plugin API whole-page export failing with a generic HTTP error [#10076](https://github.com/penpot/penpot/issues/10076) (PR: [#10148](https://github.com/penpot/penpot/pull/10148))
|
||||||
|
- Fix Plugin API applyToken failing on padding properties [#10077](https://github.com/penpot/penpot/issues/10077) (PR: [#10087](https://github.com/penpot/penpot/pull/10087))
|
||||||
|
- Fix Plugin API crash when instancing or detaching variants [#10099](https://github.com/penpot/penpot/issues/10099) (PR: [#10140](https://github.com/penpot/penpot/pull/10140))
|
||||||
|
- Fix black square appearing on first view mode launch [#10188](https://github.com/penpot/penpot/issues/10188) (PR: [#10210](https://github.com/penpot/penpot/pull/10210))
|
||||||
|
- Fix Plugin API openPage() not switching active page within the same execution [#10195](https://github.com/penpot/penpot/issues/10195) (PR: [#10085](https://github.com/penpot/penpot/pull/10085))
|
||||||
|
- Fix Plugin API appending shapes to the active page instead of the specified .root [#10196](https://github.com/penpot/penpot/issues/10196) (PR: [#10271](https://github.com/penpot/penpot/pull/10271))
|
||||||
|
- Fix Plugin API createPage() not making the new page active synchronously [#10197](https://github.com/penpot/penpot/issues/10197) (PR: [#10085](https://github.com/penpot/penpot/pull/10085))
|
||||||
|
- Fix Plugin API Flow.startingBoard returning null after createFlow [#10203](https://github.com/penpot/penpot/issues/10203) (PR: [#10244](https://github.com/penpot/penpot/pull/10244))
|
||||||
|
- Fix Plugin API page.flows returning null instead of empty array when page has no flows [#10204](https://github.com/penpot/penpot/issues/10204) (PR: [#10246](https://github.com/penpot/penpot/pull/10246))
|
||||||
|
- Fix Plugin API auto-creating a stray 'Flow 1' when wiring interactions [#10205](https://github.com/penpot/penpot/issues/10205) (PR: [#10231](https://github.com/penpot/penpot/pull/10231))
|
||||||
|
- Fix Plugin API createText throwing error when called with an empty string [#10206](https://github.com/penpot/penpot/issues/10206) (PR: [#10219](https://github.com/penpot/penpot/pull/10219))
|
||||||
|
- Fix export rendering auto-width text with a substituted fallback font [#10208](https://github.com/penpot/penpot/issues/10208) (PR: [#10238](https://github.com/penpot/penpot/pull/10238))
|
||||||
|
- Expose 'Fix when scrolling' (sticky) constraint in the Plugin API [#10209](https://github.com/penpot/penpot/issues/10209) (PR: [#10218](https://github.com/penpot/penpot/pull/10218))
|
||||||
|
- Fix long typography token name in remap modal [#10301](https://github.com/penpot/penpot/issues/10301) (PR: [#10356](https://github.com/penpot/penpot/pull/10356))
|
||||||
|
- Fix 'Go to your Penpot' link on error page redirecting to the same team instead of user home [#10324](https://github.com/penpot/penpot/issues/10324) (PR: [#10322](https://github.com/penpot/penpot/pull/10322))
|
||||||
|
- Fix premature WASM view-interaction end during pan/zoom pause [#10424](https://github.com/penpot/penpot/issues/10424) (PR: [#10425](https://github.com/penpot/penpot/pull/10425))
|
||||||
|
- Fix emojis without fill but with outer stroke not being rendered [#10473](https://github.com/penpot/penpot/issues/10473) (PR: [#10519](https://github.com/penpot/penpot/pull/10519))
|
||||||
|
- Fix texts with lots of emojis not being rendered across multiple tiles [#10474](https://github.com/penpot/penpot/issues/10474) (PR: [#10504](https://github.com/penpot/penpot/pull/10504))
|
||||||
|
- Fix artifacts in texts with inner strokes [#10476](https://github.com/penpot/penpot/issues/10476) (PR: [#10509](https://github.com/penpot/penpot/pull/10509))
|
||||||
|
- Fix open overlay position control showing only center icon instead of full grid [#10176](https://github.com/penpot/penpot/issues/10176) (PR: [#10512](https://github.com/penpot/penpot/pull/10512))
|
||||||
|
- Fix wrong text color in selection size badge [#10256](https://github.com/penpot/penpot/issues/10256) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
||||||
|
- Fix several issues with plugins [#10394](https://github.com/penpot/penpot/issues/10394)
|
||||||
|
- Fix pixel grid rendered on top of the rulers [#10426](https://github.com/penpot/penpot/issues/10426) (PR: [#10430](https://github.com/penpot/penpot/pull/10430))
|
||||||
|
- Fix double-clicking text without fills creating a black file [#10472](https://github.com/penpot/penpot/issues/10472) (PR: [#10483](https://github.com/penpot/penpot/pull/10483))
|
||||||
|
- Fix SVG raw caching prevented by unnecessary Cow wrapping [#10488](https://github.com/penpot/penpot/issues/10488) (PR: [#10492](https://github.com/penpot/penpot/pull/10492))
|
||||||
|
- Add component reset operation to plugin API [#10561](https://github.com/penpot/penpot/issues/10561) (PR: [#10533](https://github.com/penpot/penpot/pull/10533))
|
||||||
|
- Fix blur menu alignment in Firefox [#10576](https://github.com/penpot/penpot/issues/10576) (PR: [#10575](https://github.com/penpot/penpot/pull/10575))
|
||||||
|
|
||||||
## 2.16.2
|
## 2.16.2
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
@ -31,6 +189,7 @@
|
|||||||
### :boom: Breaking changes & Deprecations
|
### :boom: Breaking changes & Deprecations
|
||||||
|
|
||||||
### :rocket: Epics and highlights
|
### :rocket: Epics and highlights
|
||||||
|
|
||||||
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
|
- WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113))
|
||||||
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
|
- Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358)
|
||||||
|
|
||||||
@ -86,6 +245,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))
|
- 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
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
|
- Fix plugin API `Board.addRulerGuide` attaching guides to the page instead of the board due to a shadowed `id` binding; also correct the `'content:write'` permission error message and the `RulerGuideProxy` name (by @girafic) [#8225](https://github.com/penpot/penpot/issues/8225) (PR: [#8632](https://github.com/penpot/penpot/pull/8632))
|
||||||
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
|
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
|
||||||
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
|
- Save and restore selection state in undo/redo (by @eureka0928) [#6007](https://github.com/penpot/penpot/issues/6007) (PR: [#8652](https://github.com/penpot/penpot/pull/8652))
|
||||||
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
|
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
|
||||||
@ -160,6 +321,8 @@
|
|||||||
- Fix publishing or unpublishing file as library failing with unexpected state found error [#10094](https://github.com/penpot/penpot/issues/10094) (PR: [#10093](https://github.com/penpot/penpot/pull/10093))
|
- Fix publishing or unpublishing file as library failing with unexpected state found error [#10094](https://github.com/penpot/penpot/issues/10094) (PR: [#10093](https://github.com/penpot/penpot/pull/10093))
|
||||||
- Fix team invitation failing when email address contains consecutive dots in domain [#10097](https://github.com/penpot/penpot/issues/10097) (PR: [#10096](https://github.com/penpot/penpot/pull/10096))
|
- Fix team invitation failing when email address contains consecutive dots in domain [#10097](https://github.com/penpot/penpot/issues/10097) (PR: [#10096](https://github.com/penpot/penpot/pull/10096))
|
||||||
- Add detailed error messages for unspecified import errors [#9759](https://github.com/penpot/penpot/issues/9759) (PR: [#9886](https://github.com/penpot/penpot/pull/9886))
|
- Add detailed error messages for unspecified import errors [#9759](https://github.com/penpot/penpot/issues/9759) (PR: [#9886](https://github.com/penpot/penpot/pull/9886))
|
||||||
|
- Fix mask with children not applying blur correctly [#10004](https://github.com/penpot/penpot/issues/10004) (PR: [#10028](https://github.com/penpot/penpot/pull/10028))
|
||||||
|
- Fix WebGL italic font mismatch when custom font variants are mapped [#10060](https://github.com/penpot/penpot/issues/10060) (PR: [#10122](https://github.com/penpot/penpot/pull/10122))
|
||||||
|
|
||||||
|
|
||||||
## 2.15.4
|
## 2.15.4
|
||||||
@ -194,7 +357,6 @@
|
|||||||
|
|
||||||
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
|
- Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565)
|
||||||
|
|
||||||
|
|
||||||
## 2.15.1
|
## 2.15.1
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
### :sparkles: New features & Enhancements
|
||||||
@ -205,7 +367,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))
|
- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138))
|
||||||
|
|
||||||
|
|
||||||
## 2.15.0
|
## 2.15.0
|
||||||
|
|
||||||
### :sparkles: New features & Enhancements
|
### :sparkles: New features & Enhancements
|
||||||
|
|||||||
@ -159,7 +159,7 @@ To save time on both sides, please avoid submitting PRs that:
|
|||||||
|
|
||||||
### Good first issues
|
### 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
|
## Commit Guidelines
|
||||||
|
|
||||||
@ -175,26 +175,26 @@ Commit messages must follow this format:
|
|||||||
|
|
||||||
### Commit types
|
### Commit types
|
||||||
|
|
||||||
| Emoji | Description |
|
| Emoji | Code | Description |
|
||||||
|-------|-------------|
|
| ---------------------- | ------------------------ | -------------------------- |
|
||||||
| :bug: | Bug fix |
|
| :bug: | `:bug:` | Bug fix |
|
||||||
| :sparkles: | Improvement or enhancement |
|
| :sparkles: | `:sparkles:` | Improvement or enhancement |
|
||||||
| :tada: | New feature |
|
| :tada: | `:tada:` | New feature |
|
||||||
| :recycle: | Refactor |
|
| :recycle: | `:recycle:` | Refactor |
|
||||||
| :lipstick: | Cosmetic changes |
|
| :lipstick: | `:lipstick:` | Cosmetic changes |
|
||||||
| :ambulance: | Critical bug fix |
|
| :ambulance: | `:ambulance:` | Critical bug fix |
|
||||||
| :books: | Documentation |
|
| :books: | `:books:` | Documentation |
|
||||||
| :construction: | Work in progress |
|
| :construction: | `:construction:` | Work in progress |
|
||||||
| :boom: | Breaking change |
|
| :boom: | `:boom:` | Breaking change |
|
||||||
| :wrench: | Configuration update |
|
| :wrench: | `:wrench:` | Configuration update |
|
||||||
| :zap: | Performance improvement |
|
| :zap: | `:zap:` | Performance improvement |
|
||||||
| :whale: | Docker-related change |
|
| :whale: | `:whale:` | Docker-related change |
|
||||||
| :paperclip: | Other non-relevant changes |
|
| :paperclip: | `:paperclip:` | Other non-relevant changes |
|
||||||
| :arrow_up: | Dependency update |
|
| :arrow_up: | `:arrow_up:` | Dependency update |
|
||||||
| :arrow_down: | Dependency downgrade |
|
| :arrow_down: | `:arrow_down:` | Dependency downgrade |
|
||||||
| :fire: | Removal of code or files |
|
| :fire: | `:fire:` | Removal of code or files |
|
||||||
| :globe_with_meridians: | Add or update translations |
|
| :globe_with_meridians: | `:globe_with_meridians:` | Add or update translations |
|
||||||
| :rocket: | Epic or highlight |
|
| :rocket: | `:rocket:` | Epic or highlight |
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
@ -231,6 +231,19 @@ We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
|
|||||||
./scripts/lint
|
./scripts/lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For frontend SCSS, we use `stylelint` for linting and
|
||||||
|
`Prettier` for formatting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Lint SCSS
|
||||||
|
pnpm run lint:scss (does not modify files)
|
||||||
|
|
||||||
|
# Fix SCSS formatting (modifies files in place)
|
||||||
|
pnpm run fmt:scss
|
||||||
|
```
|
||||||
|
|
||||||
Ideally, run these as git pre-commit hooks.
|
Ideally, run these as git pre-commit hooks.
|
||||||
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
||||||
setting this up.
|
setting this up.
|
||||||
@ -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:
|
> By making a contribution to this project, I certify that:
|
||||||
>
|
>
|
||||||
> (a) The contribution was created in whole or in part by me and I have the
|
> (a) The contribution was created in whole or in part by me and I have the
|
||||||
> right to submit it under the open source license indicated in the file; or
|
> right to submit it under the open source license indicated in the file; or
|
||||||
>
|
>
|
||||||
> (b) The contribution is based upon previous work that, to the best of my
|
> (b) The contribution is based upon previous work that, to the best of my
|
||||||
> knowledge, is covered under an appropriate open source license and I have
|
> knowledge, is covered under an appropriate open source license and I have
|
||||||
> the right under that license to submit that work with modifications,
|
> the right under that license to submit that work with modifications,
|
||||||
> whether created in whole or in part by me, under the same open source
|
> whether created in whole or in part by me, under the same open source
|
||||||
> license (unless I am permitted to submit under a different license), as
|
> license (unless I am permitted to submit under a different license), as
|
||||||
> indicated in the file; or
|
> indicated in the file; or
|
||||||
>
|
>
|
||||||
> (c) The contribution was provided directly to me by some other person who
|
> (c) The contribution was provided directly to me by some other person who
|
||||||
> certified (a), (b) or (c) and I have not modified it.
|
> certified (a), (b) or (c) and I have not modified it.
|
||||||
>
|
>
|
||||||
> (d) I understand and agree that this project and the contribution are public
|
> (d) I understand and agree that this project and the contribution are public
|
||||||
> and that a record of the contribution (including all personal information
|
> and that a record of the contribution (including all personal information
|
||||||
> I submit with it, including my sign-off) is maintained indefinitely and
|
> I submit with it, including my sign-off) is maintained indefinitely and
|
||||||
> may be redistributed consistent with this project or the open source
|
> may be redistributed consistent with this project or the open source
|
||||||
> license(s) involved.
|
> license(s) involved.
|
||||||
|
|
||||||
### Signed-off-by
|
### Signed-off-by
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
|
<a href="https://www.digitalpublicgoods.net/r/penpot" rel="nofollow">
|
||||||
<img alt="Verified DPG" src="https://img.shields.io/badge/Verified-DPG-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>
|
||||||
<a href="https://community.penpot.app" rel="nofollow">
|
<a href="https://community.penpot.app" rel="nofollow">
|
||||||
<img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app">
|
<img alt="Penpot Community" src="https://img.shields.io/discourse/posts?server=https%3A%2F%2Fcommunity.penpot.app">
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
We take the security of this project seriously. If you have discovered
|
We take the security of this project seriously. If you have discovered
|
||||||
a security vulnerability, please do **not** open a public issue.
|
a security vulnerability, please do **not** open a public issue.
|
||||||
|
|
||||||
Please report vulnerabilities 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:
|
### What to include:
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@ list.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
* Noob Researcher
|
||||||
|
* Kirubakaran N
|
||||||
|
* Alisher (@7megaumka7)
|
||||||
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
|
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
|
||||||
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
|
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
|
||||||
* Vaibhav Shukla
|
* Vaibhav Shukla
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
org.clojure/clojure {:mvn/version "1.12.5"}
|
org.clojure/clojure {:mvn/version "1.12.5"}
|
||||||
org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
org.clojure/tools.namespace {:mvn/version "1.5.1"}
|
||||||
|
|
||||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-10"}
|
com.github.luben/zstd-jni {:mvn/version "1.5.7-11"}
|
||||||
|
|
||||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
||||||
@ -39,7 +39,7 @@
|
|||||||
metosin/reitit-core {:mvn/version "0.10.1"}
|
metosin/reitit-core {:mvn/version "0.10.1"}
|
||||||
nrepl/nrepl {:mvn/version "1.7.0"}
|
nrepl/nrepl {:mvn/version "1.7.0"}
|
||||||
|
|
||||||
org.postgresql/postgresql {:mvn/version "42.7.11"}
|
org.postgresql/postgresql {:mvn/version "42.7.12"}
|
||||||
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
|
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
|
||||||
|
|
||||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||||
@ -52,10 +52,6 @@
|
|||||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
|
||||||
|
|
||||||
org.jsoup/jsoup {:mvn/version "1.22.2"}
|
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
|
at.yawk.lz4/lz4-java
|
||||||
{:mvn/version "1.11.0"}
|
{:mvn/version "1.11.0"}
|
||||||
@ -67,14 +63,16 @@
|
|||||||
|
|
||||||
;; Pretty Print specs
|
;; Pretty Print specs
|
||||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||||
software.amazon.awssdk/s3 {:mvn/version "2.46.7"}
|
software.amazon.awssdk/s3 {:mvn/version "2.46.18"}
|
||||||
software.amazon.awssdk/sts {:mvn/version "2.46.7"}}
|
software.amazon.awssdk/sts {:mvn/version "2.46.18"}}
|
||||||
|
|
||||||
:paths ["src" "resources" "target/classes"]
|
:paths ["src" "resources" "target/classes"]
|
||||||
:aliases
|
:aliases
|
||||||
{:dev
|
{:dev
|
||||||
{:extra-deps
|
{:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
|
||||||
{com.bhauman/rebel-readline {:mvn/version "0.1.7"}
|
"--enable-native-access=ALL-UNNAMED"]
|
||||||
|
:extra-deps
|
||||||
|
{com.bhauman/rebel-readline {:mvn/version "0.1.11"}
|
||||||
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
|
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
|
||||||
org.clojure/data.csv {:mvn/version "1.1.1"}
|
org.clojure/data.csv {:mvn/version "1.1.1"}
|
||||||
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
|
||||||
@ -88,9 +86,7 @@
|
|||||||
|
|
||||||
:test
|
:test
|
||||||
{:main-opts ["-m" "kaocha.runner"]
|
{:main-opts ["-m" "kaocha.runner"]
|
||||||
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"
|
:jvm-opts ["-Dlog4j2.configurationFile=log4j2-devenv-repl.xml"]
|
||||||
"--sun-misc-unsafe-memory-access=allow"
|
|
||||||
"--enable-native-access=ALL-UNNAMED"]
|
|
||||||
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
|
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
|
||||||
|
|
||||||
:outdated
|
:outdated
|
||||||
|
|||||||
@ -4,19 +4,19 @@
|
|||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"author": "Kaleidos INC Sucursal en España SL",
|
"author": "Kaleidos INC Sucursal en España SL",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
|
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/penpot/penpot"
|
"url": "https://github.com/penpot/penpot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"sax": "^1.4.1"
|
"sax": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.2",
|
"nodemon": "^3.1.14",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ws": "^8.17.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
||||||
|
|||||||
84
backend/pnpm-lock.yaml
generated
84
backend/pnpm-lock.yaml
generated
@ -12,18 +12,18 @@ importers:
|
|||||||
specifier: ^3.4.4
|
specifier: ^3.4.4
|
||||||
version: 3.7.2
|
version: 3.7.2
|
||||||
sax:
|
sax:
|
||||||
specifier: ^1.4.1
|
specifier: ^1.6.0
|
||||||
version: 1.4.3
|
version: 1.6.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
nodemon:
|
nodemon:
|
||||||
specifier: ^3.1.2
|
specifier: ^3.1.14
|
||||||
version: 3.1.11
|
version: 3.1.14
|
||||||
source-map-support:
|
source-map-support:
|
||||||
specifier: ^0.5.21
|
specifier: ^0.5.21
|
||||||
version: 0.5.21
|
version: 0.5.21
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.17.0
|
specifier: ^8.21.0
|
||||||
version: 8.18.3
|
version: 8.21.0
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -31,15 +31,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@4.0.4:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
binary-extensions@2.3.0:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@5.0.7:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||||
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
@ -52,9 +54,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
|
|
||||||
concat-map@0.0.1:
|
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@ -104,14 +103,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
nodemon@3.1.11:
|
nodemon@3.1.14:
|
||||||
resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==}
|
resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@ -119,8 +119,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
picomatch@2.3.1:
|
picomatch@2.3.2:
|
||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
pstree.remy@1.1.8:
|
pstree.remy@1.1.8:
|
||||||
@ -130,11 +130,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
sax@1.4.3:
|
sax@1.6.0:
|
||||||
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||||
|
engines: {node: '>=11.0.0'}
|
||||||
|
|
||||||
semver@7.7.3:
|
semver@7.8.5:
|
||||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@ -164,8 +165,8 @@ packages:
|
|||||||
undefsafe@2.0.5:
|
undefsafe@2.0.5:
|
||||||
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
|
||||||
|
|
||||||
ws@8.18.3:
|
ws@8.21.0:
|
||||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
bufferutil: ^4.0.1
|
bufferutil: ^4.0.1
|
||||||
@ -181,16 +182,15 @@ snapshots:
|
|||||||
anymatch@3.1.3:
|
anymatch@3.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
normalize-path: 3.0.0
|
normalize-path: 3.0.0
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.2
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@5.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 4.0.4
|
||||||
concat-map: 0.0.1
|
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -210,8 +210,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
|
||||||
|
|
||||||
debug@4.4.3(supports-color@5.5.0):
|
debug@4.4.3(supports-color@5.5.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@ -247,20 +245,20 @@ snapshots:
|
|||||||
|
|
||||||
luxon@3.7.2: {}
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 5.0.7
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nodemon@3.1.11:
|
nodemon@3.1.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 3.6.0
|
chokidar: 3.6.0
|
||||||
debug: 4.4.3(supports-color@5.5.0)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
ignore-by-default: 1.0.1
|
ignore-by-default: 1.0.1
|
||||||
minimatch: 3.1.2
|
minimatch: 10.2.5
|
||||||
pstree.remy: 1.1.8
|
pstree.remy: 1.1.8
|
||||||
semver: 7.7.3
|
semver: 7.8.5
|
||||||
simple-update-notifier: 2.0.0
|
simple-update-notifier: 2.0.0
|
||||||
supports-color: 5.5.0
|
supports-color: 5.5.0
|
||||||
touch: 3.1.1
|
touch: 3.1.1
|
||||||
@ -268,21 +266,21 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.2: {}
|
||||||
|
|
||||||
pstree.remy@1.1.8: {}
|
pstree.remy@1.1.8: {}
|
||||||
|
|
||||||
readdirp@3.6.0:
|
readdirp@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.2
|
||||||
|
|
||||||
sax@1.4.3: {}
|
sax@1.6.0: {}
|
||||||
|
|
||||||
semver@7.7.3: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
simple-update-notifier@2.0.0:
|
simple-update-notifier@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.8.5
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -303,4 +301,4 @@ snapshots:
|
|||||||
|
|
||||||
undefsafe@2.0.5: {}
|
undefsafe@2.0.5: {}
|
||||||
|
|
||||||
ws@8.18.3: {}
|
ws@8.21.0: {}
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
@ -254,4 +254,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -29,10 +29,8 @@
|
|||||||
style="vertical-align:top;" width="100%">
|
style="vertical-align:top;" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div style="font-family:Source Sans Pro, sans-serif;font-size:14px;line-height:150%;text-align:center;color:#64666A;">
|
||||||
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.
|
||||||
Penpot is the first Open Source design and prototyping platform meant for
|
|
||||||
cross-domain teams.
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -100,9 +98,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
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"
|
<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;"
|
style="border-radius:3px;display:block;"
|
||||||
width="24" />
|
width="24" />
|
||||||
</a>
|
</a>
|
||||||
@ -126,9 +124,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
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"
|
<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;"
|
style="border-radius:3px;display:block;"
|
||||||
width="24" />
|
width="24" />
|
||||||
</a>
|
</a>
|
||||||
@ -152,9 +150,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
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"
|
<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;"
|
style="border-radius:3px;display:block;"
|
||||||
width="24" />
|
width="24" />
|
||||||
</a>
|
</a>
|
||||||
@ -178,10 +176,36 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
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">
|
target="_blank">
|
||||||
<img height="24"
|
<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;"
|
style="border-radius:3px;display:block;"
|
||||||
width="24" />
|
width="24" />
|
||||||
</a>
|
</a>
|
||||||
@ -205,9 +229,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
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"
|
<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;"
|
style="border-radius:3px;display:block;"
|
||||||
width="24" />
|
width="24" />
|
||||||
</a>
|
</a>
|
||||||
@ -231,10 +255,36 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
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">
|
target="_blank">
|
||||||
<img height="24"
|
<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;"
|
style="border-radius:3px;display:block;"
|
||||||
width="24" />
|
width="24" />
|
||||||
</a>
|
</a>
|
||||||
@ -320,4 +370,4 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</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;">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="20" height="20" style="display:inline-block;vertical-align:middle;">
|
||||||
<tr>
|
<tr>
|
||||||
<td width="20" height="20" align="center" valign="middle"
|
<td width="20" height="20" align="center" valign="middle"
|
||||||
background="{{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">
|
style="width:20px;height:20px;text-align:center;font-weight:bold;font-size:9px;line-height:20px;color:#ffffff;background-size:cover;background-position:center;background-repeat:no-repeat;border-radius: 50%;color:black">
|
||||||
{% if organization-initials %}{{organization-initials}}{% endif %}
|
{% if organization.initials %}{{organization.initials}}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
|
<span style="display:inline-block; vertical-align: middle;padding-left:5px;height:20px;line-height: 20px;">
|
||||||
“{{ organization-name|abbreviate:25 }}”
|
{{ organization.name|abbreviate:50 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -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 }}”
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
Hello!
|
Hello!
|
||||||
|
|
||||||
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”.
|
{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization.name|abbreviate:25 }}”.
|
||||||
|
|
||||||
Accept invitation using this link:
|
Accept invitation using this link:
|
||||||
|
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
@ -241,4 +241,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
@ -249,4 +249,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
@ -179,15 +179,21 @@
|
|||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
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
|
Welcome to Penpot!</div>
|
||||||
get started building mockups and prototypes today!</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -218,7 +224,7 @@
|
|||||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
<div
|
<div
|
||||||
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@ -241,4 +247,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
Verify email.
|
Verify your Penpot account
|
||||||
|
|||||||
@ -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
|
Welcome to Penpot!
|
||||||
link below and get started building mockups and prototypes today!
|
|
||||||
|
Please verify your email to get started with your first design and collaboration.
|
||||||
|
|
||||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
||||||
|
|
||||||
Enjoy!
|
Enjoy!
|
||||||
The Penpot team.
|
|
||||||
|
The Penpot team
|
||||||
|
|||||||
270
backend/resources/app/email/renewal-notice/en.html
Normal file
270
backend/resources/app/email/renewal-notice/en.html
Normal 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>
|
||||||
1
backend/resources/app/email/renewal-notice/en.subj
Normal file
1
backend/resources/app/email/renewal-notice/en.subj
Normal file
@ -0,0 +1 @@
|
|||||||
|
Your Enterprise subscription renews on {{ renewal-date }}
|
||||||
17
backend/resources/app/email/renewal-notice/en.txt
Normal file
17
backend/resources/app/email/renewal-notice/en.txt
Normal 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.
|
||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:97px;">
|
<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;"
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
width="97" />
|
width="97" />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
[{:id "tokens-starter-kit"
|
[{:id "tokens-starter-kit"
|
||||||
:name "Design 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"
|
{:id "penpot-design-system"
|
||||||
:name "Penpot Design System | Pencil"
|
: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"
|
{:id "wireframing-kit"
|
||||||
:name "Wireframe library"
|
: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"
|
{:id "prototype-examples"
|
||||||
:name "Prototype template"
|
: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"
|
{:id "plants-app"
|
||||||
:name "UI mockup example"
|
: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"
|
{:id "tutorial-for-beginners"
|
||||||
:name "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"
|
{:id "lucide-icons"
|
||||||
:name "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"
|
{:id "font-awesome"
|
||||||
:name "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"
|
{:id "black-white-mobile-templates"
|
||||||
:name "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"
|
{:id "avataaars"
|
||||||
:name "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"
|
{:id "ux-notes"
|
||||||
:name "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"
|
{:id "whiteboarding-kit"
|
||||||
:name "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"
|
{:id "open-color-scheme"
|
||||||
:name "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"
|
{:id "flex-layout-playground"
|
||||||
:name "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"
|
{:id "welcome"
|
||||||
:name "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"}]
|
||||||
|
|||||||
@ -77,7 +77,6 @@ export JAVA_OPTS="\
|
|||||||
-Djdk.attach.allowAttachSelf \
|
-Djdk.attach.allowAttachSelf \
|
||||||
-Dlog4j2.configurationFile=log4j2-devenv.xml \
|
-Dlog4j2.configurationFile=log4j2-devenv.xml \
|
||||||
-Djdk.tracePinnedThreads=full \
|
-Djdk.tracePinnedThreads=full \
|
||||||
-Dim4java.useV7=true \
|
|
||||||
-XX:+UnlockExperimentalVMOptions \
|
-XX:+UnlockExperimentalVMOptions \
|
||||||
-XX:+UseShenandoahGC \
|
-XX:+UseShenandoahGC \
|
||||||
-XX:+UseCompactObjectHeaders \
|
-XX:+UseCompactObjectHeaders \
|
||||||
|
|||||||
@ -17,7 +17,6 @@ mv target/penpot.jar target/dist/penpot.jar
|
|||||||
cp resources/log4j2.xml target/dist/log4j2.xml
|
cp resources/log4j2.xml target/dist/log4j2.xml
|
||||||
cp scripts/run.template.sh target/dist/run.sh;
|
cp scripts/run.template.sh target/dist/run.sh;
|
||||||
cp scripts/manage.py target/dist/manage.py
|
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/run.sh;
|
||||||
chmod +x target/dist/manage.py
|
chmod +x target/dist/manage.py
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ if [ -f ./environ ]; then
|
|||||||
source ./environ
|
source ./environ
|
||||||
fi
|
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};
|
ENTRYPOINT=${1:-app.main};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -24,9 +24,11 @@
|
|||||||
[app.http.errors :as errors]
|
[app.http.errors :as errors]
|
||||||
[app.http.session :as session]
|
[app.http.session :as session]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
|
[app.util.cache :as cache]
|
||||||
[app.util.inet :as inet]
|
[app.util.inet :as inet]
|
||||||
[app.util.json :as json]
|
[app.util.json :as json]
|
||||||
[buddy.sign.jwk :as jwk]
|
[buddy.sign.jwk :as jwk]
|
||||||
@ -41,9 +43,9 @@
|
|||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defn- discover-oidc-config
|
(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")
|
(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))
|
(if (= 200 (:status rsp))
|
||||||
(let [data (-> rsp :body json/decode)
|
(let [data (-> rsp :body json/decode)
|
||||||
@ -104,8 +106,8 @@
|
|||||||
keys))
|
keys))
|
||||||
|
|
||||||
(defn- fetch-oidc-jwks
|
(defn- fetch-oidc-jwks
|
||||||
[cfg jwks-uri]
|
[cfg jwks-uri {:keys [skip-ssrf-check?]}]
|
||||||
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
|
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})]
|
||||||
(if (= 200 status)
|
(if (= 200 status)
|
||||||
(-> body json/decode :keys process-oidc-jwks)
|
(-> body json/decode :keys process-oidc-jwks)
|
||||||
(ex/raise :type ::internal
|
(ex/raise :type ::internal
|
||||||
@ -117,7 +119,8 @@
|
|||||||
"Fetch and Add (if possible) JWK's to the OIDC provider"
|
"Fetch and Add (if possible) JWK's to the OIDC provider"
|
||||||
[cfg provider]
|
[cfg provider]
|
||||||
(try
|
(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)
|
(assoc provider :jwks jwks)
|
||||||
provider)
|
provider)
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
@ -408,9 +411,9 @@
|
|||||||
(defn- build-redirect-uri
|
(defn- build-redirect-uri
|
||||||
[]
|
[]
|
||||||
(let [public (u/uri (cf/get :public-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]
|
[provider token]
|
||||||
(let [params {:client_id (:client-id provider)
|
(let [params {:client_id (:client-id provider)
|
||||||
:redirect_uri (build-redirect-uri)
|
:redirect_uri (build-redirect-uri)
|
||||||
@ -453,7 +456,7 @@
|
|||||||
:grant-type (:grant_type params)
|
:grant-type (:grant_type params)
|
||||||
:redirect-uri (:redirect_uri 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)
|
(if (= status 200)
|
||||||
(let [data (json/decode body)
|
(let [data (json/decode body)
|
||||||
data {:token/access (get data :access_token)
|
data {:token/access (get data :access_token)
|
||||||
@ -508,7 +511,7 @@
|
|||||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||||
:timeout 6000
|
:timeout 6000
|
||||||
:method :get}
|
: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"
|
(l/trc :hint "user info response"
|
||||||
:status (:status response)
|
:status (:status response)
|
||||||
@ -694,15 +697,24 @@
|
|||||||
(db/pgarray? roles)
|
(db/pgarray? roles)
|
||||||
(assoc :roles (db/decode-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
|
(defn get-provider
|
||||||
[cfg id]
|
[cfg id]
|
||||||
(try
|
(try
|
||||||
(when-let [params (some->> (db/get* cfg :sso-provider {:id id :is-enabled true})
|
(cache/get provider-cache id (partial load-provider cfg))
|
||||||
(decode-row))]
|
|
||||||
(case (:type params)
|
|
||||||
"oidc" (prepare-oidc-provider cfg params)))
|
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/err :hint "unable to configure custom SSO provider"
|
(l/err :hint "unable to configure custom SSO provider"
|
||||||
:provider (str id)
|
:provider (str id)
|
||||||
@ -745,6 +757,25 @@
|
|||||||
(assoc profile :props props'))
|
(assoc profile :props props'))
|
||||||
profile)))
|
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
|
(defn- auth-handler
|
||||||
[cfg {:keys [params] :as request}]
|
[cfg {:keys [params] :as request}]
|
||||||
(let [provider (resolve-provider cfg params)
|
(let [provider (resolve-provider cfg params)
|
||||||
@ -769,64 +800,81 @@
|
|||||||
(try
|
(try
|
||||||
(let [code (get params :code)
|
(let [code (get params :code)
|
||||||
state (get params :state)
|
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)
|
;; Org SSO flow: state carries :dest-url — exchange the authorization
|
||||||
info (get-info cfg provider state code)
|
;; code with the OIDC provider to verify authentication actually occurred.
|
||||||
profile (get-profile cfg (:email info))]
|
(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
|
(let [provider (resolve-provider cfg state)
|
||||||
(not profile)
|
info (get-info cfg provider state code)
|
||||||
(cond
|
profile (get-profile cfg (:email info))]
|
||||||
(and (email.blacklist/enabled? cfg)
|
|
||||||
(email.blacklist/contains? cfg (:email info)))
|
|
||||||
(redirect-with-error "email-domain-not-allowed")
|
|
||||||
|
|
||||||
(and (email.whitelist/enabled? cfg)
|
(cond
|
||||||
(not (email.whitelist/contains? cfg (:email info))))
|
(not profile)
|
||||||
(redirect-with-error "email-domain-not-allowed")
|
(cond
|
||||||
|
(and (email.blacklist/enabled? cfg)
|
||||||
|
(email.blacklist/contains? cfg (:email info)))
|
||||||
|
(redirect-with-error "email-domain-not-allowed")
|
||||||
|
|
||||||
:else
|
(and (email.whitelist/enabled? cfg)
|
||||||
(if (or (contains? cf/flags :registration)
|
(not (email.whitelist/contains? cfg (:email info))))
|
||||||
(contains? cf/flags :oidc-registration))
|
(redirect-with-error "email-domain-not-allowed")
|
||||||
(redirect-to-register cfg info provider)
|
|
||||||
(redirect-with-error "registration-disabled")))
|
|
||||||
|
|
||||||
(:is-blocked profile)
|
:else
|
||||||
(redirect-with-error "profile-blocked")
|
(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))
|
(:is-blocked profile)
|
||||||
(profile-has-provider-props? provider profile)
|
(redirect-with-error "profile-blocked")
|
||||||
(provider-has-email-verified? provider info)))
|
|
||||||
(redirect-with-error "auth-provider-not-allowed")
|
|
||||||
|
|
||||||
(not (:is-active profile))
|
(not (or (= (:auth-backend profile) (:type provider))
|
||||||
(let [info (assoc info :profile-id (:id profile))]
|
(profile-has-provider-props? provider profile)
|
||||||
(redirect-to-register cfg info provider))
|
(provider-has-email-verified? provider info)))
|
||||||
|
(redirect-with-error "auth-provider-not-allowed")
|
||||||
|
|
||||||
:else
|
(not (:is-active profile))
|
||||||
(let [sxf (session/create-fn cfg profile info)
|
(let [info (assoc info :profile-id (:id profile))]
|
||||||
token (or (:invitation-token info)
|
(redirect-to-register cfg info provider))
|
||||||
(tokens/generate cfg
|
|
||||||
{:iss :auth
|
|
||||||
:exp (ct/in-future "15m")
|
|
||||||
:profile-id (:id profile)}))
|
|
||||||
|
|
||||||
;; If proceed, update profile on the database
|
:else
|
||||||
profile (update-profile-with-info cfg profile info)
|
(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)
|
;; If proceed, update profile on the database
|
||||||
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
profile (update-profile-with-info cfg profile info)
|
||||||
|
|
||||||
(audit/submit cfg {:type "action"
|
props (audit/profile->props profile)
|
||||||
:name "login-with-oidc"
|
context (d/without-nils {:external-session-id (:external-session-id info)})]
|
||||||
:profile-id (:id profile)
|
|
||||||
:ip-addr (inet/parse-request request)
|
|
||||||
:props props
|
|
||||||
:context context})
|
|
||||||
|
|
||||||
(->> (redirect-to-verify-token token)
|
(audit/submit cfg {:type "action"
|
||||||
(sxf request)))))
|
: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
|
(catch Throwable cause
|
||||||
(binding [l/*context* (errors/request->context request)]
|
(binding [l/*context* (errors/request->context request)]
|
||||||
@ -839,6 +887,7 @@
|
|||||||
::http/client
|
::http/client
|
||||||
::setup/props
|
::setup/props
|
||||||
::db/pool
|
::db/pool
|
||||||
|
[:app.nitrate/client [:maybe :map]]
|
||||||
[::providers schema:providers]])
|
[::providers schema:providers]])
|
||||||
|
|
||||||
(defmethod ig/assert-key ::routes
|
(defmethod ig/assert-key ::routes
|
||||||
|
|||||||
@ -315,8 +315,8 @@
|
|||||||
(defn get-file
|
(defn get-file
|
||||||
"Get file, resolve all features and apply migrations.
|
"Get file, resolve all features and apply migrations.
|
||||||
|
|
||||||
Usefull when you have plan to apply massive or not cirurgical
|
Useful when you have plan to apply massive or not surgical
|
||||||
operations on file, because it removes the ovehead of lazy fetching
|
operations on file, because it removes the overhead of lazy fetching
|
||||||
and decoding."
|
and decoding."
|
||||||
[cfg file-id & {:as opts}]
|
[cfg file-id & {:as opts}]
|
||||||
(db/run! cfg get-file* file-id opts))
|
(db/run! cfg get-file* file-id opts))
|
||||||
@ -843,7 +843,12 @@
|
|||||||
l.vern,
|
l.vern,
|
||||||
l.is_shared,
|
l.is_shared,
|
||||||
l.version,
|
l.version,
|
||||||
fls.synced_at
|
fls.synced_at,
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM file_library_rel AS direct
|
||||||
|
WHERE direct.file_id = ?::uuid
|
||||||
|
AND direct.library_file_id = l.id
|
||||||
|
) AS is_indirect
|
||||||
FROM libs AS l
|
FROM libs AS l
|
||||||
JOIN project AS p
|
JOIN project AS p
|
||||||
ON p.id = l.project_id
|
ON p.id = l.project_id
|
||||||
@ -855,12 +860,8 @@
|
|||||||
(defn get-file-libraries
|
(defn get-file-libraries
|
||||||
[conn file-id]
|
[conn file-id]
|
||||||
(into []
|
(into []
|
||||||
(comp
|
(map decode-row-features)
|
||||||
;; FIXME: :is-indirect set to false to all rows looks
|
(db/exec! conn [sql:get-file-libraries file-id file-id file-id])))
|
||||||
;; completly useless
|
|
||||||
(map #(assoc % :is-indirect false))
|
|
||||||
(map decode-row-features))
|
|
||||||
(db/exec! conn [sql:get-file-libraries file-id file-id])))
|
|
||||||
|
|
||||||
(defn get-resolved-file-libraries
|
(defn get-resolved-file-libraries
|
||||||
"Get all file libraries including itself. Returns an instance of
|
"Get all file libraries including itself. Returns an instance of
|
||||||
|
|||||||
@ -74,6 +74,10 @@
|
|||||||
:media-max-file-size (* 1024 1024 30) ; 30MiB
|
:media-max-file-size (* 1024 1024 30) ; 30MiB
|
||||||
:font-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-user-query "(|(uid=:username)(mail=:username))"
|
||||||
:ldap-attrs-username "uid"
|
:ldap-attrs-username "uid"
|
||||||
:ldap-attrs-email "mail"
|
:ldap-attrs-email "mail"
|
||||||
@ -109,6 +113,11 @@
|
|||||||
[:http-server-io-threads {:optional true} ::sm/int]
|
[:http-server-io-threads {:optional true} ::sm/int]
|
||||||
[:http-server-max-worker-threads {:optional true} ::sm/int]
|
[:http-server-max-worker-threads {:optional true} ::sm/int]
|
||||||
|
|
||||||
|
;; Explicit CORS allowlist used when the :cors flag is enabled.
|
||||||
|
;; Configured via PENPOT_ALLOWED_ORIGINS as a comma/whitespace
|
||||||
|
;; separated list of origins (e.g. "https://plugins.example.com").
|
||||||
|
[:allowed-origins {:optional true} [::sm/set :string]]
|
||||||
|
|
||||||
[:exporter-shared-key {:optional true} :string]
|
[:exporter-shared-key {:optional true} :string]
|
||||||
[:nitrate-shared-key {:optional true} :string]
|
[:nitrate-shared-key {:optional true} :string]
|
||||||
[:nexus-shared-key {:optional true} :string]
|
[:nexus-shared-key {:optional true} :string]
|
||||||
@ -122,6 +131,22 @@
|
|||||||
|
|
||||||
[:media-max-file-size {:optional true} ::sm/int]
|
[:media-max-file-size {:optional true} ::sm/int]
|
||||||
[:font-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]
|
[:deletion-delay {:optional true} ::ct/duration]
|
||||||
[:file-clean-delay {:optional true} ::ct/duration]
|
[:file-clean-delay {:optional true} ::ct/duration]
|
||||||
[:telemetry-enabled {:optional true} ::sm/boolean]
|
[:telemetry-enabled {:optional true} ::sm/boolean]
|
||||||
@ -236,7 +261,6 @@
|
|||||||
[:assets-path {:optional true} :string]
|
[:assets-path {:optional true} :string]
|
||||||
|
|
||||||
[:netty-io-threads {:optional true} ::sm/int]
|
[:netty-io-threads {:optional true} ::sm/int]
|
||||||
[:executor-threads {:optional true} ::sm/int]
|
|
||||||
|
|
||||||
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
[:nitrate-backend-uri {:optional true} ::sm/uri]
|
||||||
|
|
||||||
@ -295,7 +319,7 @@
|
|||||||
(sm/explainer schema:config))
|
(sm/explainer schema:config))
|
||||||
|
|
||||||
(defn read-config
|
(defn read-config
|
||||||
"Reads the configuration from enviroment variables and decodes all
|
"Reads the configuration from environment variables and decodes all
|
||||||
known values."
|
known values."
|
||||||
[& {:keys [prefix default] :or {prefix "penpot"}}]
|
[& {:keys [prefix default] :or {prefix "penpot"}}]
|
||||||
(->> (read-env prefix)
|
(->> (read-env prefix)
|
||||||
|
|||||||
@ -431,14 +431,19 @@
|
|||||||
:id ::invite-to-team
|
:id ::invite-to-team
|
||||||
:schema schema:invite-to-team))
|
:schema schema:invite-to-team))
|
||||||
|
|
||||||
|
(def ^:private schema:organization-data
|
||||||
|
[:map
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:initials [:maybe :string]]
|
||||||
|
[:logo [:maybe ::sm/uri]]
|
||||||
|
[:avatar-bg-url [:maybe ::sm/uri]]])
|
||||||
|
|
||||||
(def ^:private schema:invite-to-org
|
(def ^:private schema:invite-to-org
|
||||||
[:map
|
[:map
|
||||||
[:invited-by ::sm/text]
|
[:invited-by ::sm/text]
|
||||||
[:organization-name ::sm/text]
|
|
||||||
[:organization-initials [:maybe :string]]
|
|
||||||
[:organization-logo ::sm/uri]
|
|
||||||
[:user-name [:maybe ::sm/text]]
|
[:user-name [:maybe ::sm/text]]
|
||||||
[:token ::sm/text]])
|
[:token ::sm/text]
|
||||||
|
[:organization schema:organization-data]])
|
||||||
|
|
||||||
(def invite-to-org
|
(def invite-to-org
|
||||||
"Org member invitation email."
|
"Org member invitation email."
|
||||||
@ -446,6 +451,21 @@
|
|||||||
:id ::invite-to-org
|
:id ::invite-to-org
|
||||||
:schema schema:invite-to-org))
|
:schema schema:invite-to-org))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
(def ^:private schema:renewal-notice
|
||||||
|
[:map
|
||||||
|
[:user-name [:maybe ::sm/text]]
|
||||||
|
[:renewal-date ::sm/text]
|
||||||
|
[:estimated-amount ::sm/text]
|
||||||
|
[:organizations [:vector schema:organization-data]]])
|
||||||
|
|
||||||
|
(def renewal-notice
|
||||||
|
"Enterprise subscription renewal notice email."
|
||||||
|
(template-factory
|
||||||
|
:id ::renewal-notice
|
||||||
|
:schema schema:renewal-notice))
|
||||||
|
|
||||||
(def ^:private schema:join-team
|
(def ^:private schema:join-team
|
||||||
[:map
|
[:map
|
||||||
[:invited-by ::sm/text]
|
[:invited-by ::sm/text]
|
||||||
|
|||||||
@ -22,7 +22,8 @@
|
|||||||
(and (= "unlimited" type) (not (contains? canceled-status status)))
|
(and (= "unlimited" type) (not (contains? canceled-status status)))
|
||||||
(ct/duration {:days 30})
|
(ct/duration {:days 30})
|
||||||
|
|
||||||
(and (= "enterprise" type) (not (contains? canceled-status status)))
|
(and (contains? #{"enterprise" "nitrate"} type)
|
||||||
|
(not (contains? canceled-status status)))
|
||||||
(ct/duration {:days 90})
|
(ct/duration {:days 90})
|
||||||
|
|
||||||
:else
|
:else
|
||||||
|
|||||||
@ -144,6 +144,15 @@
|
|||||||
{::yres/status 404
|
{::yres/status 404
|
||||||
::yres/body (ex-data err)})
|
::yres/body (ex-data err)})
|
||||||
|
|
||||||
|
(defmethod handle-error :nitrate-unavailable
|
||||||
|
[err request _]
|
||||||
|
(binding [l/*context* (request->context request)]
|
||||||
|
(l/warn :hint "nitrate is unreachable; blocking request" :cause err)
|
||||||
|
;; Do not leak Nitrate's internal URL/status to the client; the
|
||||||
|
;; full context is already logged above for operators.
|
||||||
|
{::yres/status 503
|
||||||
|
::yres/body {:type :nitrate-unavailable}}))
|
||||||
|
|
||||||
(defmethod handle-error :internal
|
(defmethod handle-error :internal
|
||||||
[error request parent-cause]
|
[error request parent-cause]
|
||||||
(binding [l/*context* (request->context request)]
|
(binding [l/*context* (request->context request)]
|
||||||
|
|||||||
@ -208,28 +208,40 @@
|
|||||||
:compile (constantly wrap-errors)})
|
:compile (constantly wrap-errors)})
|
||||||
|
|
||||||
(defn- with-cors-headers
|
(defn- with-cors-headers
|
||||||
[headers origin]
|
"Build CORS response headers. Only emits permissive headers when the
|
||||||
(-> headers
|
request `origin` is present on the configured `allowed` allowlist;
|
||||||
(assoc "access-control-allow-origin" origin)
|
otherwise returns the headers unchanged except for `Vary: Origin` so
|
||||||
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
|
shared caches don't leak per-origin responses."
|
||||||
(assoc "access-control-allow-credentials" "true")
|
[headers origin allowed]
|
||||||
(assoc "access-control-expose-headers" "content-type, set-cookie")
|
(cond-> (assoc headers "vary" "Origin")
|
||||||
(assoc "access-control-allow-headers" "x-frontend-version, x-client, x-requested-width, content-type, accept, cookie")))
|
(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
|
(defn wrap-cors
|
||||||
[handler]
|
[handler allowed]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [response (if (= (yreq/method request) :options)
|
(let [response (if (= (yreq/method request) :options)
|
||||||
{::yres/status 204}
|
{::yres/status 204}
|
||||||
(handler request))
|
(handler request))
|
||||||
origin (yreq/get-header request "origin")]
|
origin (yreq/get-header request "origin")]
|
||||||
(update response ::yres/headers with-cors-headers origin))))
|
(update response ::yres/headers with-cors-headers origin allowed))))
|
||||||
|
|
||||||
(def cors
|
(def cors
|
||||||
{:name ::cors
|
{:name ::cors
|
||||||
:compile (fn [& _]
|
:compile (fn [& _]
|
||||||
(when (contains? cf/flags :cors)
|
(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
|
(def restrict-methods
|
||||||
{:name ::restrict-methods
|
{:name ::restrict-methods
|
||||||
|
|||||||
@ -68,17 +68,24 @@
|
|||||||
(def ^:private valid-params?
|
(def ^:private valid-params?
|
||||||
(sm/validator schema:params))
|
(sm/validator schema:params))
|
||||||
|
|
||||||
|
(defn- decode-session
|
||||||
|
[session]
|
||||||
|
(cond-> session
|
||||||
|
(db/pgobject? (:props session))
|
||||||
|
(update :props db/decode-transit-pgobject)))
|
||||||
|
|
||||||
(defn- database-manager
|
(defn- database-manager
|
||||||
[pool]
|
[pool]
|
||||||
(reify ISessionManager
|
(reify ISessionManager
|
||||||
(read-session [_ id]
|
(read-session [_ id]
|
||||||
(if (string? 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}))]
|
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
|
||||||
(-> session
|
(-> session
|
||||||
(assoc :modified-at (:updated-at session))
|
(assoc :modified-at (:updated-at session))
|
||||||
(dissoc :updated-at)))
|
(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]
|
(create-session [_ params]
|
||||||
(assert (valid-params? params) "expect valid session params")
|
(assert (valid-params? params) "expect valid session params")
|
||||||
@ -100,7 +107,9 @@
|
|||||||
(assoc :created-at modified-at)
|
(assoc :created-at modified-at)
|
||||||
(assoc :modified-at modified-at)))
|
(assoc :modified-at modified-at)))
|
||||||
(db/update! pool :http-session-v2
|
(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)}
|
{:id (:id session)}
|
||||||
{::db/return-keys true}))))
|
{::db/return-keys true}))))
|
||||||
|
|
||||||
@ -129,9 +138,10 @@
|
|||||||
session))
|
session))
|
||||||
|
|
||||||
(update-session [_ session]
|
(update-session [_ session]
|
||||||
(let [modified-at (ct/now)]
|
(let [modified-at (ct/now)
|
||||||
(swap! cache update (:id session) assoc :modified-at modified-at)
|
session (assoc session :modified-at modified-at)]
|
||||||
(assoc session :modified-at modified-at)))
|
(swap! cache assoc (:id session) session)
|
||||||
|
session))
|
||||||
|
|
||||||
(delete-session [_ id]
|
(delete-session [_ id]
|
||||||
(swap! cache dissoc id)
|
(swap! cache dissoc id)
|
||||||
@ -216,6 +226,20 @@
|
|||||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||||
(db/get-update-count))))
|
(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?
|
(defn- renew-session?
|
||||||
[{:keys [id modified-at] :as session}]
|
[{:keys [id modified-at] :as session}]
|
||||||
(or (string? id)
|
(or (string? id)
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
(defn- write!
|
(defn- write!
|
||||||
[^OutputStream output ^bytes data]
|
[^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)
|
(.write output data)
|
||||||
(.flush output))
|
(.flush output))
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,6 @@
|
|||||||
[app.storage.gc-deleted :as-alias sto.gc-deleted]
|
[app.storage.gc-deleted :as-alias sto.gc-deleted]
|
||||||
[app.storage.gc-touched :as-alias sto.gc-touched]
|
[app.storage.gc-touched :as-alias sto.gc-touched]
|
||||||
[app.storage.s3 :as-alias sto.s3]
|
[app.storage.s3 :as-alias sto.s3]
|
||||||
[app.svgo :as-alias svgo]
|
|
||||||
[app.util.cron]
|
[app.util.cron]
|
||||||
[app.worker :as-alias wrk]
|
[app.worker :as-alias wrk]
|
||||||
[app.worker.executor]
|
[app.worker.executor]
|
||||||
@ -162,8 +161,8 @@
|
|||||||
::wrk/netty-io-executor
|
::wrk/netty-io-executor
|
||||||
{:threads (cf/get :netty-io-threads)}
|
{:threads (cf/get :netty-io-threads)}
|
||||||
|
|
||||||
::wrk/netty-executor
|
::wrk/executor
|
||||||
{:threads (cf/get :executor-threads)}
|
{}
|
||||||
|
|
||||||
:app.migrations/migrations
|
:app.migrations/migrations
|
||||||
{::db/pool (ig/ref ::db/pool)}
|
{::db/pool (ig/ref ::db/pool)}
|
||||||
@ -178,9 +177,6 @@
|
|||||||
{::rds/uri
|
{::rds/uri
|
||||||
(cf/get :redis-uri)
|
(cf/get :redis-uri)
|
||||||
|
|
||||||
::wrk/netty-executor
|
|
||||||
(ig/ref ::wrk/netty-executor)
|
|
||||||
|
|
||||||
::wrk/netty-io-executor
|
::wrk/netty-io-executor
|
||||||
(ig/ref ::wrk/netty-io-executor)}
|
(ig/ref ::wrk/netty-io-executor)}
|
||||||
|
|
||||||
@ -189,12 +185,12 @@
|
|||||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||||
|
|
||||||
::mbus/msgbus
|
::mbus/msgbus
|
||||||
{::wrk/executor (ig/ref ::wrk/netty-executor)
|
{::wrk/executor (ig/ref ::wrk/executor)
|
||||||
::rds/client (ig/ref ::rds/client)
|
::rds/client (ig/ref ::rds/client)
|
||||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||||
|
|
||||||
:app.storage.tmp/cleaner
|
:app.storage.tmp/cleaner
|
||||||
{::wrk/executor (ig/ref ::wrk/netty-executor)}
|
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||||
|
|
||||||
::sto.gc-deleted/handler
|
::sto.gc-deleted/handler
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
@ -265,7 +261,8 @@
|
|||||||
::oidc/providers (ig/ref ::oidc/providers)
|
::oidc/providers (ig/ref ::oidc/providers)
|
||||||
::session/manager (ig/ref ::session/manager)
|
::session/manager (ig/ref ::session/manager)
|
||||||
::email/blacklist (ig/ref ::email/blacklist)
|
::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
|
::mgmt/routes
|
||||||
{::db/pool (ig/ref ::db/pool)
|
{::db/pool (ig/ref ::db/pool)
|
||||||
@ -308,12 +305,12 @@
|
|||||||
|
|
||||||
::rpc/climit
|
::rpc/climit
|
||||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
{::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/config (cf/get :rpc-climit-config)
|
||||||
::climit/enabled (contains? cf/flags :rpc-climit)}
|
::climit/enabled (contains? cf/flags :rpc-climit)}
|
||||||
|
|
||||||
:app.rpc/rlimit
|
:app.rpc/rlimit
|
||||||
{::wrk/executor (ig/ref ::wrk/netty-executor)
|
{::wrk/executor (ig/ref ::wrk/executor)
|
||||||
|
|
||||||
:app.loggers.mattermost/reporter
|
:app.loggers.mattermost/reporter
|
||||||
(ig/ref :app.loggers.mattermost/reporter)
|
(ig/ref :app.loggers.mattermost/reporter)
|
||||||
@ -325,8 +322,8 @@
|
|||||||
{::http.client/client (ig/ref ::http.client/client)
|
{::http.client/client (ig/ref ::http.client/client)
|
||||||
::db/pool (ig/ref ::db/pool)
|
::db/pool (ig/ref ::db/pool)
|
||||||
::rds/pool (ig/ref ::rds/pool)
|
::rds/pool (ig/ref ::rds/pool)
|
||||||
:app.nitrate/client (ig/ref :app.nitrate/client)
|
:app.nitrate/client (ig/ref :app.nitrate/client)
|
||||||
::wrk/executor (ig/ref ::wrk/netty-executor)
|
::wrk/executor (ig/ref ::wrk/executor)
|
||||||
::session/manager (ig/ref ::session/manager)
|
::session/manager (ig/ref ::session/manager)
|
||||||
::ldap/provider (ig/ref ::ldap/provider)
|
::ldap/provider (ig/ref ::ldap/provider)
|
||||||
::sto/storage (ig/ref ::sto/storage)
|
::sto/storage (ig/ref ::sto/storage)
|
||||||
@ -356,12 +353,12 @@
|
|||||||
{::http.client/client (ig/ref ::http.client/client)
|
{::http.client/client (ig/ref ::http.client/client)
|
||||||
::db/pool (ig/ref ::db/pool)
|
::db/pool (ig/ref ::db/pool)
|
||||||
::rds/pool (ig/ref ::rds/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)
|
::session/manager (ig/ref ::session/manager)
|
||||||
::sto/storage (ig/ref ::sto/storage)
|
::sto/storage (ig/ref ::sto/storage)
|
||||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
::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)
|
::rds/client (ig/ref ::rds/client)
|
||||||
::setup/props (ig/ref ::setup/props)}
|
::setup/props (ig/ref ::setup/props)}
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,9 @@
|
|||||||
[app.media.sanitize :as sanitize]
|
[app.media.sanitize :as sanitize]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.shell :as shell]
|
||||||
[buddy.core.bytes :as bb]
|
[buddy.core.bytes :as bb]
|
||||||
[buddy.core.codecs :as bc]
|
[buddy.core.codecs :as bc]
|
||||||
[clojure.java.shell :as sh]
|
|
||||||
[clojure.string]
|
[clojure.string]
|
||||||
[clojure.xml :as xml]
|
[clojure.xml :as xml]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
@ -34,9 +34,7 @@
|
|||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
javax.xml.parsers.SAXParserFactory
|
javax.xml.parsers.SAXParserFactory
|
||||||
javax.xml.XMLConstants
|
javax.xml.XMLConstants
|
||||||
org.apache.commons.io.IOUtils
|
org.apache.commons.io.IOUtils))
|
||||||
org.im4java.core.ConvertCmd
|
|
||||||
org.im4java.core.IMOperation))
|
|
||||||
|
|
||||||
(def schema:upload
|
(def schema:upload
|
||||||
[:map {:title "Upload"}
|
[:map {:title "Upload"}
|
||||||
@ -90,25 +88,17 @@
|
|||||||
max-size)))
|
max-size)))
|
||||||
upload))
|
upload))
|
||||||
|
|
||||||
(defmulti process :cmd)
|
(defmulti process (fn [_system params] (:cmd params)))
|
||||||
(defmulti process-error class)
|
|
||||||
|
|
||||||
(defmethod process :default
|
(defmethod process :default
|
||||||
[{:keys [cmd] :as params}]
|
[_system {:keys [cmd] :as params}]
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :not-implemented
|
:code :not-implemented
|
||||||
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
|
:hint (str/fmt "No impl found for process cmd: %s" cmd)))
|
||||||
|
|
||||||
(defmethod process-error :default
|
|
||||||
[error]
|
|
||||||
(throw error))
|
|
||||||
|
|
||||||
(defn run
|
(defn run
|
||||||
[params]
|
[system params]
|
||||||
(try
|
(process system params))
|
||||||
(process params)
|
|
||||||
(catch Throwable e
|
|
||||||
(process-error e))))
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; SVG PARSING
|
;; SVG PARSING
|
||||||
@ -152,16 +142,63 @@
|
|||||||
;; Related info on how thumbnails generation
|
;; Related info on how thumbnails generation
|
||||||
;; http://www.imagemagick.org/Usage/thumbnails/
|
;; 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
|
(defn- generic-process
|
||||||
[{:keys [input format operation] :as params}]
|
[system {:keys [input format convert-args] :as params}]
|
||||||
(let [{:keys [path mtype]} input
|
(let [{:keys [path mtype]} input
|
||||||
format (or (cm/mtype->format mtype) format)
|
format (or format (cm/mtype->format mtype))
|
||||||
ext (cm/format->extension format)
|
ext (cm/format->extension format)
|
||||||
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)]
|
tmp (tmp/tempfile :prefix "penpot.media." :suffix ext)
|
||||||
|
args (into [(str path)] (conj (vec convert-args) (str tmp)))]
|
||||||
(doto (ConvertCmd.)
|
(exec-magick! system args)
|
||||||
(.run operation (into-array (map str [path tmp]))))
|
|
||||||
|
|
||||||
(assoc params
|
(assoc params
|
||||||
:format format
|
:format format
|
||||||
:mtype (cm/format->mtype format)
|
:mtype (cm/format->mtype format)
|
||||||
@ -169,38 +206,26 @@
|
|||||||
:data tmp)))
|
:data tmp)))
|
||||||
|
|
||||||
(defmethod process :generic-thumbnail
|
(defmethod process :generic-thumbnail
|
||||||
[params]
|
[system params]
|
||||||
(let [{:keys [quality width height] :as params}
|
(let [{:keys [quality width height] :as params}
|
||||||
(check-thumbnail-params params)
|
(check-thumbnail-params params)]
|
||||||
|
(generic-process system
|
||||||
operation
|
(assoc params
|
||||||
(doto (IMOperation.)
|
:convert-args ["-auto-orient" "-strip"
|
||||||
(.addImage)
|
"-thumbnail" (str width "x" height ">")
|
||||||
(.autoOrient)
|
"-quality" (str quality)]))))
|
||||||
(.strip)
|
|
||||||
(.thumbnail ^Integer (int width) ^Integer (int height) ">")
|
|
||||||
(.quality (double quality))
|
|
||||||
(.addImage))]
|
|
||||||
|
|
||||||
(generic-process (assoc params :operation operation))))
|
|
||||||
|
|
||||||
(defmethod process :profile-thumbnail
|
(defmethod process :profile-thumbnail
|
||||||
[params]
|
[system params]
|
||||||
(let [{:keys [quality width height] :as params}
|
(let [{:keys [quality width height] :as params}
|
||||||
(check-thumbnail-params params)
|
(check-thumbnail-params params)]
|
||||||
|
(generic-process system
|
||||||
operation
|
(assoc params
|
||||||
(doto (IMOperation.)
|
:convert-args ["-auto-orient" "-strip"
|
||||||
(.addImage)
|
"-thumbnail" (str width "x" height "^")
|
||||||
(.autoOrient)
|
"-gravity" "center"
|
||||||
(.strip)
|
"-extent" (str width "x" height)
|
||||||
(.thumbnail ^Integer (int width) ^Integer (int height) "^")
|
"-quality" (str quality)]))))
|
||||||
(.gravity "center")
|
|
||||||
(.extent (int width) (int height))
|
|
||||||
(.quality (double quality))
|
|
||||||
(.addImage))]
|
|
||||||
|
|
||||||
(generic-process (assoc params :operation operation))))
|
|
||||||
|
|
||||||
(defn get-basic-info-from-svg
|
(defn get-basic-info-from-svg
|
||||||
[{:keys [tag attrs] :as data}]
|
[{:keys [tag attrs] :as data}]
|
||||||
@ -230,11 +255,11 @@
|
|||||||
{:width (int width)
|
{:width (int width)
|
||||||
:height (int height)})))]))
|
: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
|
;; 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
|
;; 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)
|
(let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path])
|
||||||
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
|
orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])]
|
||||||
(when (= 0 (:exit dim-result))
|
(when (= 0 (:exit dim-result))
|
||||||
(let [[w h] (-> (:out dim-result)
|
(let [[w h] (-> (:out dim-result)
|
||||||
str/trim
|
str/trim
|
||||||
@ -249,7 +274,7 @@
|
|||||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||||
|
|
||||||
(defmethod process :info
|
(defmethod process :info
|
||||||
[{:keys [input] :as params}]
|
[system {:keys [input] :as params}]
|
||||||
(let [{:keys [path mtype] :as input} (check-input input)]
|
(let [{:keys [path mtype] :as input} (check-input input)]
|
||||||
(if (= mtype "image/svg+xml")
|
(if (= mtype "image/svg+xml")
|
||||||
(let [info (some-> path slurp parse-svg get-basic-info-from-svg)]
|
(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)}))
|
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||||
|
|
||||||
(let [path-str (str 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
|
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||||
mtype' (if (zero? (:exit identify-res))
|
mtype' (if (zero? (:exit identify-res))
|
||||||
(-> identify-res
|
(-> identify-res
|
||||||
@ -273,7 +298,7 @@
|
|||||||
:code :invalid-image
|
:code :invalid-image
|
||||||
:hint "invalid image"))
|
:hint "invalid image"))
|
||||||
{:keys [width height]}
|
{:keys [width height]}
|
||||||
(or (get-dimensions-with-orientation path-str)
|
(or (get-dimensions-with-orientation system path-str)
|
||||||
(do
|
(do
|
||||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
@ -291,13 +316,6 @@
|
|||||||
:size (fs/size path)
|
:size (fs/size path)
|
||||||
:ts (ct/now))))))
|
: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
|
;; IMAGE HELPERS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -370,48 +388,80 @@
|
|||||||
;; FONTS
|
;; 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
|
(defmethod process :generate-fonts
|
||||||
[{:keys [input] :as params}]
|
[system {:keys [input] :as params}]
|
||||||
(letfn [(ttf->otf [data]
|
(letfn [(ttf->otf [data]
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
foutput (fs/path (str finput ".otf"))
|
foutput (fs/path (str finput ".otf"))]
|
||||||
_ (io/write* finput data)
|
(try
|
||||||
res (sh/sh "fontforge" "-lang=ff" "-c"
|
(io/write* finput data)
|
||||||
(str/fmt "Open('%s'); Generate('%s')"
|
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||||
(str finput)
|
(str/fmt "Open('%s'); Generate('%s')"
|
||||||
(str foutput)))]
|
(str finput)
|
||||||
(when (zero? (:exit res))
|
(str foutput))])]
|
||||||
foutput)))
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
(otf->ttf [data]
|
(otf->ttf [data]
|
||||||
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
foutput (fs/path (str finput ".ttf"))
|
foutput (fs/path (str finput ".ttf"))]
|
||||||
_ (io/write* finput data)
|
(try
|
||||||
res (sh/sh "fontforge" "-lang=ff" "-c"
|
(io/write* finput data)
|
||||||
(str/fmt "Open('%s'); Generate('%s')"
|
(let [res (exec-font! system ["fontforge" "-lang=ff" "-c"
|
||||||
(str finput)
|
(str/fmt "Open('%s'); Generate('%s')"
|
||||||
(str foutput)))]
|
(str finput)
|
||||||
(when (zero? (:exit res))
|
(str foutput))])]
|
||||||
foutput)))
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
(ttf-or-otf->woff [data]
|
(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 "")
|
(let [finput (tmp/tempfile :prefix "penpot.font." :suffix "")
|
||||||
foutput (fs/path (str finput ".woff"))
|
foutput (fs/path (str finput ".woff"))]
|
||||||
_ (io/write* finput data)
|
(try
|
||||||
res (sh/sh "sfnt2woff" (str finput))]
|
(io/write* finput data)
|
||||||
(when (zero? (:exit res))
|
(let [res (exec-font! system ["sfnt2woff" (str finput)])]
|
||||||
foutput)))
|
(when (zero? (:exit res))
|
||||||
|
foutput))
|
||||||
|
(finally
|
||||||
|
(fs/delete finput)))))
|
||||||
|
|
||||||
(woff->sfnt [data]
|
(woff->sfnt [data]
|
||||||
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")
|
(let [finput (tmp/tempfile :prefix "penpot" :suffix "")]
|
||||||
_ (io/write* finput data)
|
(try
|
||||||
res (sh/sh "woff2sfnt" (str finput)
|
(io/write* finput data)
|
||||||
:out-enc :bytes)]
|
(let [res (shell/exec! system
|
||||||
(when (zero? (:exit res))
|
:cmd ["woff2sfnt" (str finput)]
|
||||||
(:out res))))
|
: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->sfnt [data]
|
||||||
;; woff2_decompress outputs to same directory with .ttf extension
|
;; woff2_decompress outputs to same directory with .ttf extension
|
||||||
@ -419,7 +469,7 @@
|
|||||||
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))]
|
||||||
(try
|
(try
|
||||||
(io/write* finput data)
|
(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))
|
(if (zero? (:exit res))
|
||||||
foutput
|
foutput
|
||||||
(do
|
(do
|
||||||
|
|||||||
@ -486,6 +486,9 @@
|
|||||||
{:name "0149-mod-file-library-rel-synced-at"
|
{: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")}
|
||||||
|
|
||||||
{:name "0150-mod-storage-object-table"
|
{:name "0150-mod-storage-object-table"
|
||||||
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE http_session_v2
|
||||||
|
ADD COLUMN props jsonb NULL;
|
||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.nitrate
|
(ns app.nitrate
|
||||||
"Module that make calls to the external nitrate aplication"
|
"Module that make calls to the external nitrate aplication"
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.json :as json]
|
[app.common.json :as json]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
@ -16,6 +17,7 @@
|
|||||||
[app.common.types.organization :as cto]
|
[app.common.types.organization :as cto]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
|
[app.http.session :as session]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[clojure.core :as c]
|
[clojure.core :as c]
|
||||||
@ -28,14 +30,16 @@
|
|||||||
(defn- request-builder
|
(defn- request-builder
|
||||||
[cfg method uri shared-key profile-id request-params]
|
[cfg method uri shared-key profile-id request-params]
|
||||||
(fn []
|
(fn []
|
||||||
(http/req cfg (cond-> {:method method
|
(http/req cfg
|
||||||
:headers {"content-type" "application/json"
|
(cond-> {:method method
|
||||||
"accept" "application/json"
|
:headers {"content-type" "application/json"
|
||||||
"x-shared-key" shared-key
|
"accept" "application/json"
|
||||||
"x-profile-id" (str profile-id)}
|
"x-shared-key" shared-key
|
||||||
:uri uri
|
"x-profile-id" (str profile-id)}
|
||||||
:version :http1.1}
|
:uri uri
|
||||||
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))))))
|
:version :http1.1}
|
||||||
|
(= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))
|
||||||
|
{:skip-ssrf-check? true})))
|
||||||
|
|
||||||
(defn- with-retries
|
(defn- with-retries
|
||||||
[handler max-retries]
|
[handler max-retries]
|
||||||
@ -59,14 +63,29 @@
|
|||||||
(fn []
|
(fn []
|
||||||
(let [response (handler)
|
(let [response (handler)
|
||||||
status (:status response)]
|
status (:status response)]
|
||||||
(when-not status
|
|
||||||
(l/error :hint "could't do the nitrate request, it is probably down"
|
|
||||||
:uri uri)
|
|
||||||
;; TODO decide what to do when Nitrate is inaccesible
|
|
||||||
nil)
|
|
||||||
(cond
|
(cond
|
||||||
|
(nil? status)
|
||||||
|
(do
|
||||||
|
(l/error :hint "couldn't do the nitrate request, it is probably down"
|
||||||
|
:uri uri)
|
||||||
|
(ex/raise :type :nitrate-unavailable
|
||||||
|
:hint (str "nitrate is unreachable at " uri)))
|
||||||
|
|
||||||
|
(>= status 500)
|
||||||
|
;; Nitrate is up enough to answer (or the proxy is) but the
|
||||||
|
;; service itself is failing; treat as unavailable so callers
|
||||||
|
;; surface the static error page.
|
||||||
|
(do
|
||||||
|
(l/error :hint "nitrate request failed with server error status"
|
||||||
|
:uri uri
|
||||||
|
:status status
|
||||||
|
:body (:body response))
|
||||||
|
(ex/raise :type :nitrate-unavailable
|
||||||
|
:status status
|
||||||
|
:hint (str "nitrate is unavailable, HTTP " status " at " uri)))
|
||||||
|
|
||||||
(>= status 400)
|
(>= status 400)
|
||||||
;; For error status codes (4xx, 5xx), fail immediately without validation
|
;; For client error status codes (4xx), fail immediately without validation
|
||||||
(do
|
(do
|
||||||
(when (not= status 404) ;; Don't need to log 404
|
(when (not= status 404) ;; Don't need to log 404
|
||||||
(l/error :hint "nitrate request failed with error status"
|
(l/error :hint "nitrate request failed with error status"
|
||||||
@ -171,6 +190,7 @@
|
|||||||
"day"
|
"day"
|
||||||
"week"
|
"week"
|
||||||
"year"]]
|
"year"]]
|
||||||
|
[:manual :boolean]
|
||||||
[:quantity :int]
|
[:quantity :int]
|
||||||
[:description [:maybe ::sm/text]]
|
[:description [:maybe ::sm/text]]
|
||||||
[:created-at schema:timestamp]
|
[:created-at schema:timestamp]
|
||||||
@ -256,6 +276,42 @@
|
|||||||
[:vector schema:org-summary]
|
[:vector schema:org-summary]
|
||||||
params)))
|
params)))
|
||||||
|
|
||||||
|
(def ^:private schema:org-summary-counts
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:slug ::sm/text]
|
||||||
|
[:team-count ::sm/int]
|
||||||
|
[:member-count ::sm/int]
|
||||||
|
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||||
|
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
|
||||||
|
|
||||||
|
(defn- get-owned-orgs-summary-api
|
||||||
|
[cfg {:keys [profile-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
|
orgs (request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/users/"
|
||||||
|
profile-id
|
||||||
|
"/owned-organizations-summary")
|
||||||
|
[:vector schema:org-summary-counts]
|
||||||
|
params)]
|
||||||
|
(mapv (fn [org]
|
||||||
|
(if-let [logo-id (:logo-id org)]
|
||||||
|
(assoc org :custom-photo (str (cf/get :public-uri) "/assets/by-id/" logo-id))
|
||||||
|
org))
|
||||||
|
orgs)))
|
||||||
|
|
||||||
|
(defn- 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
|
(defn- set-team-org-api
|
||||||
[cfg {:keys [organization-id team-id is-default] :as params}]
|
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
@ -267,7 +323,7 @@
|
|||||||
organization-id
|
organization-id
|
||||||
"/add-team")
|
"/add-team")
|
||||||
cto/schema:team-with-organization params)
|
cto/schema:team-with-organization params)
|
||||||
custom-photo (when-let [logo-id (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))]
|
(str (cf/get :public-uri) "/assets/by-id/" logo-id))]
|
||||||
(cond-> team
|
(cond-> team
|
||||||
custom-photo
|
custom-photo
|
||||||
@ -297,16 +353,6 @@
|
|||||||
"/remove-user")
|
"/remove-user")
|
||||||
nil params)))
|
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
|
(defn- remove-team-from-org-api
|
||||||
[cfg {:keys [team-id organization-id] :as params}]
|
[cfg {:keys [team-id organization-id] :as params}]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
@ -336,6 +382,24 @@
|
|||||||
profile-id)
|
profile-id)
|
||||||
schema:subscription params)))
|
schema:subscription params)))
|
||||||
|
|
||||||
|
(def ^:private schema:subscription-warning
|
||||||
|
[:maybe
|
||||||
|
[:map {:title "SubscriptionWarning"}
|
||||||
|
[:type {:optional true} ::sm/text]
|
||||||
|
[:days-from-expiry {:optional true} ::sm/int]
|
||||||
|
[:days-until-expiry {:optional true} ::sm/int]
|
||||||
|
[:expiration-date {:optional true} schema:timestamp]]])
|
||||||
|
|
||||||
|
(defn- get-subscription-warning-api
|
||||||
|
[cfg {:keys [penpot-id profile-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)
|
||||||
|
penpot-id (or penpot-id profile-id)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/subscription-warning/"
|
||||||
|
penpot-id)
|
||||||
|
schema:subscription-warning params)))
|
||||||
|
|
||||||
(defn- get-connectivity-api
|
(defn- get-connectivity-api
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
@ -348,6 +412,53 @@
|
|||||||
[:map
|
[:map
|
||||||
[:cancel-at [:maybe schema:timestamp]]])
|
[:cancel-at [:maybe schema:timestamp]]])
|
||||||
|
|
||||||
|
(defn- get-org-permissions-api
|
||||||
|
[cfg {:keys [organization-id] :as params}]
|
||||||
|
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||||
|
(request-to-nitrate cfg :get
|
||||||
|
(str baseuri
|
||||||
|
"/api/organizations/"
|
||||||
|
organization-id
|
||||||
|
"/permissions")
|
||||||
|
[:map
|
||||||
|
[:organization-id ::sm/uuid]
|
||||||
|
[:owner-id ::sm/uuid]
|
||||||
|
[:permissions [:map-of :keyword :string]]]
|
||||||
|
params)))
|
||||||
|
|
||||||
|
(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
|
(defn- redeem-activation-code-api
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
(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-membership-by-team (partial get-org-membership-by-team-api cfg)
|
||||||
:get-org-summary (partial get-org-summary-api cfg)
|
:get-org-summary (partial get-org-summary-api cfg)
|
||||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||||
|
:get-owned-orgs-summary (partial get-owned-orgs-summary-api cfg)
|
||||||
|
:get-org-members (partial get-org-members-api cfg)
|
||||||
|
:cleanup-deleted-penpot-user (partial cleanup-deleted-penpot-user-api cfg)
|
||||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||||
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
||||||
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
|
: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)
|
:delete-team (partial delete-team-api cfg)
|
||||||
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
:remove-team-from-org (partial remove-team-from-org-api cfg)
|
||||||
:get-subscription (partial get-subscription-api cfg)
|
:get-subscription (partial get-subscription-api cfg)
|
||||||
|
:get-subscription-warning (partial get-subscription-warning-api cfg)
|
||||||
:connectivity (partial get-connectivity-api cfg)
|
:connectivity (partial get-connectivity-api cfg)
|
||||||
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
:redeem-activation-code (partial redeem-activation-code-api cfg)}))
|
||||||
|
|
||||||
@ -382,25 +498,49 @@
|
|||||||
;; UTILS
|
;; 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
|
(defn add-nitrate-licence-to-profile
|
||||||
"Enriches a profile map with subscription information from Nitrate.
|
"Enriches a profile map with subscription information from Nitrate.
|
||||||
Adds a :subscription field containing the user's license details.
|
Adds a :subscription field containing the user's license details.
|
||||||
Returns the original profile unchanged if the request fails."
|
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]
|
[cfg profile]
|
||||||
(try
|
(try
|
||||||
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
|
(let [subscription (call cfg :get-subscription {:profile-id (:id profile)})]
|
||||||
(assoc profile :subscription subscription))
|
(assoc profile :subscription subscription))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/error :hint "failed to get nitrate licence"
|
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||||
:profile-id (:id profile)
|
(throw cause)
|
||||||
:cause cause)
|
(do
|
||||||
profile)))
|
(l/error :hint "failed to get nitrate licence"
|
||||||
|
:profile-id (:id profile)
|
||||||
|
:cause cause)
|
||||||
|
profile)))))
|
||||||
|
|
||||||
(defn add-org-info-to-team
|
(defn add-org-info-to-team
|
||||||
"Enriches a team map with organization information from Nitrate.
|
"Enriches a team map with organization information from Nitrate.
|
||||||
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields.
|
||||||
Returns the original team unchanged if the request fails or org data is nil."
|
Returns the original team unchanged if the request fails or org data is nil.
|
||||||
|
Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable."
|
||||||
[cfg team params]
|
[cfg team params]
|
||||||
(try
|
(try
|
||||||
(let [params (assoc (or params {}) :team-id (:id team))
|
(let [params (assoc (or params {}) :team-id (:id team))
|
||||||
@ -413,10 +553,13 @@
|
|||||||
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
(assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org)))))
|
||||||
team))
|
team))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/error :hint "failed to get team organization info"
|
(if (= :nitrate-unavailable (-> cause ex-data :type))
|
||||||
:team-id (:id team)
|
(throw cause)
|
||||||
:cause cause)
|
(do
|
||||||
team)))
|
(l/error :hint "failed to get team organization info"
|
||||||
|
:team-id (:id team)
|
||||||
|
:cause cause)
|
||||||
|
team)))))
|
||||||
|
|
||||||
(defn set-team-organization
|
(defn set-team-organization
|
||||||
"Associates a team with an organization in Nitrate.
|
"Associates a team with an organization in Nitrate.
|
||||||
@ -434,7 +577,3 @@
|
|||||||
:context {:team-id (:id team)
|
:context {:team-id (:id team)
|
||||||
:organization-id (:organization-id params)}))
|
:organization-id (:organization-id params)}))
|
||||||
team))
|
team))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,6 @@
|
|||||||
io.lettuce.core.ScriptOutputType
|
io.lettuce.core.ScriptOutputType
|
||||||
io.lettuce.core.SetArgs
|
io.lettuce.core.SetArgs
|
||||||
io.netty.channel.nio.NioEventLoopGroup
|
io.netty.channel.nio.NioEventLoopGroup
|
||||||
io.netty.util.concurrent.EventExecutorGroup
|
|
||||||
io.netty.util.HashedWheelTimer
|
io.netty.util.HashedWheelTimer
|
||||||
io.netty.util.Timer
|
io.netty.util.Timer
|
||||||
java.lang.AutoCloseable
|
java.lang.AutoCloseable
|
||||||
@ -527,7 +526,6 @@
|
|||||||
(def ^:private schema:client-params
|
(def ^:private schema:client-params
|
||||||
[:map {:title "redis-params"}
|
[:map {:title "redis-params"}
|
||||||
::wrk/netty-io-executor
|
::wrk/netty-io-executor
|
||||||
::wrk/netty-executor
|
|
||||||
[::uri ::sm/uri]
|
[::uri ::sm/uri]
|
||||||
[::timeout ::ct/duration]])
|
[::timeout ::ct/duration]])
|
||||||
|
|
||||||
@ -539,7 +537,7 @@
|
|||||||
(check-client-params params))
|
(check-client-params params))
|
||||||
|
|
||||||
(defmethod ig/init-key ::client
|
(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))
|
(l/inf :hint "initialize redis client" :uri (str uri))
|
||||||
|
|
||||||
@ -547,7 +545,6 @@
|
|||||||
cache (atom {})
|
cache (atom {})
|
||||||
|
|
||||||
resources (.. (DefaultClientResources/builder)
|
resources (.. (DefaultClientResources/builder)
|
||||||
(eventExecutorGroup ^EventExecutorGroup netty-executor)
|
|
||||||
|
|
||||||
;; We provide lettuce with a shared event loop
|
;; We provide lettuce with a shared event loop
|
||||||
;; group instance instead of letting lettuce to
|
;; group instance instead of letting lettuce to
|
||||||
|
|||||||
@ -27,8 +27,10 @@
|
|||||||
[app.main :as-alias main]
|
[app.main :as-alias main]
|
||||||
[app.metrics :as mtx]
|
[app.metrics :as mtx]
|
||||||
[app.msgbus :as-alias mbus]
|
[app.msgbus :as-alias mbus]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.redis :as rds]
|
[app.redis :as rds]
|
||||||
[app.rpc.climit :as climit]
|
[app.rpc.climit :as climit]
|
||||||
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.cond :as cond]
|
[app.rpc.cond :as cond]
|
||||||
[app.rpc.doc :as doc]
|
[app.rpc.doc :as doc]
|
||||||
[app.rpc.helpers :as rph]
|
[app.rpc.helpers :as rph]
|
||||||
@ -36,6 +38,7 @@
|
|||||||
[app.rpc.rlimit :as rlimit]
|
[app.rpc.rlimit :as rlimit]
|
||||||
[app.setup :as-alias setup]
|
[app.setup :as-alias setup]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
|
[app.util.cache :as cache]
|
||||||
[app.util.inet :as inet]
|
[app.util.inet :as inet]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
@ -208,6 +211,74 @@
|
|||||||
::sm/explain (explain params)))))))
|
::sm/explain (explain params)))))))
|
||||||
f))
|
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
|
(defn- wrap
|
||||||
[cfg f mdata]
|
[cfg f mdata]
|
||||||
(as-> f $
|
(as-> f $
|
||||||
@ -220,7 +291,8 @@
|
|||||||
(wrap-audit cfg $ mdata)
|
(wrap-audit cfg $ mdata)
|
||||||
(wrap-spec-conform cfg $ mdata)
|
(wrap-spec-conform cfg $ mdata)
|
||||||
(wrap-params-validation cfg $ mdata)
|
(wrap-params-validation cfg $ mdata)
|
||||||
(wrap-authentication cfg $ mdata)))
|
(wrap-authentication cfg $ mdata)
|
||||||
|
(wrap-nitrate-sso cfg $ mdata)))
|
||||||
|
|
||||||
(defn- wrap-management
|
(defn- wrap-management
|
||||||
[cfg f mdata]
|
[cfg f mdata]
|
||||||
@ -232,7 +304,10 @@
|
|||||||
(wrap-audit cfg $ mdata)
|
(wrap-audit cfg $ mdata)
|
||||||
(wrap-spec-conform cfg $ mdata)
|
(wrap-spec-conform cfg $ mdata)
|
||||||
(wrap-params-validation cfg $ mdata)
|
(wrap-params-validation cfg $ mdata)
|
||||||
(wrap-authentication cfg $ mdata)))
|
(wrap-authentication cfg $ mdata)
|
||||||
|
(wrap-nitrate-sso cfg $ mdata)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn- process-method
|
(defn- process-method
|
||||||
[cfg wrap-fn [f mdata]]
|
[cfg wrap-fn [f mdata]]
|
||||||
|
|||||||
@ -320,7 +320,7 @@
|
|||||||
(try
|
(try
|
||||||
(let [storage (sto/resolve cfg)
|
(let [storage (sto/resolve cfg)
|
||||||
input (media/download-image cfg uri)
|
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))
|
hash (sto/calculate-hash (:path input))
|
||||||
content (-> (sto/content (:path input) (:size input))
|
content (-> (sto/content (:path input) (:size input))
|
||||||
(sto/wrap-with-hash hash))
|
(sto/wrap-with-hash hash))
|
||||||
@ -545,6 +545,12 @@
|
|||||||
::audit/context {:action "email-verification"}
|
::audit/context {:action "email-verification"}
|
||||||
::audit/profile-id (:id profile)})))))
|
::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
|
:else
|
||||||
(let [elapsed? (elapsed-verify-threshold? profile)
|
(let [elapsed? (elapsed-verify-threshold? profile)
|
||||||
reports? (eml/has-reports? conn (:email profile))
|
reports? (eml/has-reports? conn (:email profile))
|
||||||
|
|||||||
@ -165,6 +165,7 @@
|
|||||||
(sv/defmethod ::get-file
|
(sv/defmethod ::get-file
|
||||||
"Retrieve a file by its ID. Only authenticated users."
|
"Retrieve a file by its ID. Only authenticated users."
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :file
|
||||||
::cond/get-object #(get-minimal-file-with-perms %1 %2)
|
::cond/get-object #(get-minimal-file-with-perms %1 %2)
|
||||||
::cond/key-fn get-file-etag
|
::cond/key-fn get-file-etag
|
||||||
::sm/params schema:get-file
|
::sm/params schema:get-file
|
||||||
@ -601,6 +602,7 @@
|
|||||||
(sv/defmethod ::get-file-summary
|
(sv/defmethod ::get-file-summary
|
||||||
"Retrieve a file summary by its ID. Only authenticated users."
|
"Retrieve a file summary by its ID. Only authenticated users."
|
||||||
{::doc/added "1.20"
|
{::doc/added "1.20"
|
||||||
|
::rpc/id-type :file
|
||||||
::sm/params schema:get-file-summary}
|
::sm/params schema:get-file-summary}
|
||||||
[cfg {:keys [::rpc/profile-id id] :as params}]
|
[cfg {:keys [::rpc/profile-id id] :as params}]
|
||||||
(check-read-permissions! cfg profile-id id)
|
(check-read-permissions! cfg profile-id id)
|
||||||
@ -669,6 +671,7 @@
|
|||||||
outbound library reference counts. Cheap alternative to `get-file`
|
outbound library reference counts. Cheap alternative to `get-file`
|
||||||
when only metrics are needed."
|
when only metrics are needed."
|
||||||
{::doc/added "2.17"
|
{::doc/added "2.17"
|
||||||
|
::rpc/id-type :file
|
||||||
::sm/params schema:get-file-stats
|
::sm/params schema:get-file-stats
|
||||||
::sm/result schema:get-file-stats-result
|
::sm/result schema:get-file-stats-result
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
@ -842,6 +845,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::rename-file
|
(sv/defmethod ::rename-file
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :file
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
|
|
||||||
::sm/webhook
|
::sm/webhook
|
||||||
@ -1001,6 +1005,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::set-file-shared
|
(sv/defmethod ::set-file-shared
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :file
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:set-file-shared}
|
::sm/params schema:set-file-shared}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
@ -1056,6 +1061,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::delete-file
|
(sv/defmethod ::delete-file
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :file
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:delete-file}
|
::sm/params schema:delete-file}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
|
|||||||
@ -112,22 +112,30 @@
|
|||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/project-id project-id})
|
::quotes/project-id project-id})
|
||||||
|
|
||||||
;; FIXME: IMPORTANT: this code can have race conditions, because
|
;; Acquire a row-level lock on the team and re-read its features
|
||||||
;; we have no locks for updating team so, creating two files
|
;; inside the same transaction before the read-modify-write below.
|
||||||
;; concurrently can lead to lost team features updating
|
;; Without the lock, two concurrent create-file calls on the same
|
||||||
(when-let [features (-> features
|
;; team can both observe the same team.features value, each
|
||||||
(set/difference (:features team))
|
;; compute a different union, and the second UPDATE silently
|
||||||
(set/difference cfeat/no-team-inheritable-features)
|
;; overwrites the first (lost update under READ COMMITTED).
|
||||||
(not-empty))]
|
(let [team-features (-> (db/exec-one! conn
|
||||||
(let [features (-> features
|
["SELECT features FROM team WHERE id = ? FOR UPDATE"
|
||||||
(set/union (:features team))
|
team-id])
|
||||||
(set/difference cfeat/no-team-inheritable-features)
|
:features
|
||||||
(into-array))]
|
(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
|
(db/update! conn :team
|
||||||
{:features features}
|
{:features features}
|
||||||
{:id (:id team)}
|
{:id team-id}
|
||||||
{::db/return-keys false})))
|
{::db/return-keys false}))))
|
||||||
|
|
||||||
(-> (create-file cfg params)
|
(-> (create-file cfg params)
|
||||||
(vary-meta assoc ::audit/props {:team-id team-id}))))
|
(vary-meta assoc ::audit/props {:team-id team-id}))))
|
||||||
|
|||||||
@ -452,10 +452,7 @@
|
|||||||
|
|
||||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||||
;; TODO For now we check read permissions instead of write,
|
(files/check-edition-permissions! conn profile-id file-id)
|
||||||
;; to allow viewer users to update thumbnails. We might
|
|
||||||
;; review this approach on the future.
|
|
||||||
(files/check-read-permissions! conn profile-id file-id)
|
|
||||||
(when-not (db/read-only? conn)
|
(when-not (db/read-only? conn)
|
||||||
(let [media (create-file-thumbnail cfg params)]
|
(let [media (create-file-thumbnail cfg params)]
|
||||||
{:uri (files/resolve-public-uri (:id media))
|
{:uri (files/resolve-public-uri (:id media))
|
||||||
|
|||||||
@ -130,7 +130,8 @@
|
|||||||
;; database.
|
;; database.
|
||||||
|
|
||||||
(sv/defmethod ::update-file
|
(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]]
|
[:update-file/global]]
|
||||||
|
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
|
|||||||
@ -109,9 +109,6 @@
|
|||||||
(fn [{:keys [data uploads]}]
|
(fn [{:keys [data uploads]}]
|
||||||
(or (seq data) (seq uploads)))]])
|
(or (seq data) (seq uploads)))]])
|
||||||
|
|
||||||
;; FIXME: IMPORTANT: refactor this, we should not hold a whole db
|
|
||||||
;; connection around the font creation
|
|
||||||
|
|
||||||
(defn- prepare-font-data-from-uploads
|
(defn- prepare-font-data-from-uploads
|
||||||
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
"Assembles each chunked-upload session in `uploads` (a `{mtype →
|
||||||
session-id}` map) into a temp file, validates the media type and
|
session-id}` map) into a temp file, validates the media type and
|
||||||
@ -171,22 +168,20 @@
|
|||||||
[:process-font/global]]
|
[:process-font/global]]
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:create-font-variant}
|
::sm/params schema:create-font-variant}
|
||||||
[cfg {:keys [::rpc/profile-id team-id uploads] :as params}]
|
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
|
||||||
(db/tx-run! cfg
|
(teams/check-edition-permissions! pool profile-id team-id)
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
||||||
(teams/check-edition-permissions! conn profile-id team-id)
|
::quotes/profile-id profile-id
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
|
::quotes/team-id team-id})
|
||||||
::quotes/profile-id profile-id
|
(let [params (if (some? uploads)
|
||||||
::quotes/team-id team-id})
|
(db/tx-run! cfg prepare-font-data-from-uploads params)
|
||||||
(let [params (if (some? uploads)
|
(prepare-font-data-from-legacy params))]
|
||||||
(prepare-font-data-from-uploads cfg params)
|
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
||||||
(prepare-font-data-from-legacy params))]
|
|
||||||
(create-font-variant cfg (assoc params :profile-id profile-id))))))
|
|
||||||
|
|
||||||
(defn create-font-variant
|
(defn create-font-variant
|
||||||
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
|
[{:keys [::sto/storage] :as cfg} {:keys [data] :as params}]
|
||||||
(letfn [(generate-missing [data]
|
(letfn [(generate-missing [data]
|
||||||
(let [data (media/run {:cmd :generate-fonts :input data})]
|
(let [data (media/run cfg {:cmd :generate-fonts :input data})]
|
||||||
(when (and (not (contains? data "font/otf"))
|
(when (and (not (contains? data "font/otf"))
|
||||||
(not (contains? data "font/ttf"))
|
(not (contains? data "font/ttf"))
|
||||||
(not (contains? data "font/woff"))
|
(not (contains? data "font/woff"))
|
||||||
@ -209,22 +204,15 @@
|
|||||||
:bucket "team-font-variant"})))
|
:bucket "team-font-variant"})))
|
||||||
|
|
||||||
(persist-fonts-files! [data]
|
(persist-fonts-files! [data]
|
||||||
(let [otf-params (prepare-font data "font/otf")
|
(into {} (keep (fn [[kind mtype]]
|
||||||
ttf-params (prepare-font data "font/ttf")
|
(when-let [params (prepare-font data mtype)]
|
||||||
wf1-params (prepare-font data "font/woff")
|
[kind (sto/put-object! storage params)])))
|
||||||
wf2-params (prepare-font data "font/woff2")]
|
[[:otf "font/otf"]
|
||||||
|
[:ttf "font/ttf"]
|
||||||
|
[:woff1 "font/woff"]
|
||||||
|
[:woff2 "font/woff2"]]))
|
||||||
|
|
||||||
(cond-> {}
|
(insert-font-variant! [conn {:keys [woff1 woff2 otf ttf]}]
|
||||||
(some? otf-params)
|
|
||||||
(assoc :otf (sto/put-object! storage otf-params))
|
|
||||||
(some? ttf-params)
|
|
||||||
(assoc :ttf (sto/put-object! storage ttf-params))
|
|
||||||
(some? wf1-params)
|
|
||||||
(assoc :woff1 (sto/put-object! storage wf1-params))
|
|
||||||
(some? wf2-params)
|
|
||||||
(assoc :woff2 (sto/put-object! storage wf2-params)))))
|
|
||||||
|
|
||||||
(insert-font-variant! [{:keys [woff1 woff2 otf ttf]}]
|
|
||||||
(db/insert! conn :team-font-variant
|
(db/insert! conn :team-font-variant
|
||||||
{:id (uuid/next)
|
{:id (uuid/next)
|
||||||
:team-id (:team-id params)
|
:team-id (:team-id params)
|
||||||
@ -238,14 +226,14 @@
|
|||||||
:otf-file-id (:id otf)
|
:otf-file-id (:id otf)
|
||||||
:ttf-file-id (:id ttf)}))]
|
:ttf-file-id (:id ttf)}))]
|
||||||
|
|
||||||
(let [tpoint (ct/tpoint)
|
(let [tpoint (ct/tpoint)
|
||||||
mtypes (vec (keys data))
|
mtypes (vec (keys data))
|
||||||
total-size (reduce-kv (fn [acc _ content]
|
total-size (reduce-kv (fn [acc _ content]
|
||||||
(+ acc (if (bytes? content)
|
(+ acc (if (bytes? content)
|
||||||
(alength ^bytes content)
|
(alength ^bytes content)
|
||||||
(fs/size content))))
|
(fs/size content))))
|
||||||
0
|
0
|
||||||
data)]
|
data)]
|
||||||
|
|
||||||
(l/dbg :hint "create-font-variant"
|
(l/dbg :hint "create-font-variant"
|
||||||
:step "init"
|
:step "init"
|
||||||
@ -257,7 +245,7 @@
|
|||||||
|
|
||||||
(let [data (generate-missing data)
|
(let [data (generate-missing data)
|
||||||
assets (persist-fonts-files! data)
|
assets (persist-fonts-files! data)
|
||||||
result (insert-font-variant! assets)
|
result (db/tx-run! cfg #(insert-font-variant! (::db/conn %) assets))
|
||||||
elapsed (tpoint)]
|
elapsed (tpoint)]
|
||||||
|
|
||||||
(l/dbg :hint "create-font-variant"
|
(l/dbg :hint "create-font-variant"
|
||||||
|
|||||||
@ -123,11 +123,10 @@
|
|||||||
:bucket "file-media-object"}))
|
:bucket "file-media-object"}))
|
||||||
|
|
||||||
(defn- process-thumb-image
|
(defn- process-thumb-image
|
||||||
[info]
|
[cfg info]
|
||||||
(let [thumb (-> thumbnail-options
|
(let [thumb (media/run cfg (assoc thumbnail-options
|
||||||
(assoc :cmd :generic-thumbnail)
|
:cmd :generic-thumbnail
|
||||||
(assoc :input info)
|
:input info))
|
||||||
(media/run))
|
|
||||||
hash (sto/calculate-hash (:data thumb))
|
hash (sto/calculate-hash (:data thumb))
|
||||||
data (-> (sto/content (:data thumb) (:size thumb))
|
data (-> (sto/content (:data thumb) (:size thumb))
|
||||||
(sto/wrap-with-hash hash))]
|
(sto/wrap-with-hash hash))]
|
||||||
@ -138,12 +137,12 @@
|
|||||||
:bucket "file-media-object"}))
|
:bucket "file-media-object"}))
|
||||||
|
|
||||||
(defn- process-image
|
(defn- process-image
|
||||||
[content]
|
[cfg content]
|
||||||
(let [info (media/run {:cmd :info :input content})]
|
(let [info (media/run cfg {:cmd :info :input content})]
|
||||||
(cond-> info
|
(cond-> info
|
||||||
(and (not (svg-image? info))
|
(and (not (svg-image? info))
|
||||||
(big-enough-for-thumbnail? info))
|
(big-enough-for-thumbnail? info))
|
||||||
(assoc ::thumb (process-thumb-image info))
|
(assoc ::thumb (process-thumb-image cfg info))
|
||||||
|
|
||||||
:always
|
:always
|
||||||
(assoc ::image (process-main-image info)))))
|
(assoc ::image (process-main-image info)))))
|
||||||
@ -170,7 +169,7 @@
|
|||||||
:path (str (:path content))
|
:path (str (:path content))
|
||||||
:origin origin)
|
:origin origin)
|
||||||
|
|
||||||
(let [result (process-image content)
|
(let [result (process-image cfg content)
|
||||||
image (sto/put-object! storage (::image result))
|
image (sto/put-object! storage (::image result))
|
||||||
thumb (when-let [params (::thumb result)]
|
thumb (when-let [params (::thumb result)]
|
||||||
(sto/put-object! storage params))
|
(sto/put-object! storage params))
|
||||||
|
|||||||
@ -8,16 +8,21 @@
|
|||||||
"Nitrate API for Penpot. Provides nitrate-related endpoints to be called
|
"Nitrate API for Penpot. Provides nitrate-related endpoints to be called
|
||||||
from Penpot frontend."
|
from Penpot frontend."
|
||||||
(:require
|
(:require
|
||||||
|
[app.auth.oidc :as oidc]
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.doc :as-alias doc]
|
[app.rpc.doc :as-alias doc]
|
||||||
|
[app.rpc.helpers :as rph]
|
||||||
[app.rpc.notifications :as notifications]
|
[app.rpc.notifications :as notifications]
|
||||||
|
[app.tokens :as tokens]
|
||||||
[app.util.services :as sv]))
|
[app.util.services :as sv]))
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +62,22 @@
|
|||||||
[cfg _params]
|
[cfg _params]
|
||||||
(nitrate/call cfg :connectivity {}))
|
(nitrate/call cfg :connectivity {}))
|
||||||
|
|
||||||
|
(def ^:private schema:subscription-warning
|
||||||
|
[:maybe
|
||||||
|
[:map {:title "SubscriptionWarning"}
|
||||||
|
[:type {:optional true} ::sm/text]
|
||||||
|
[:days-from-expiry {:optional true} ::sm/int]
|
||||||
|
[:days-until-expiry {:optional true} ::sm/int]
|
||||||
|
[:expiration-date {:optional true} ct/schema:inst]]])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-subscription-warning
|
||||||
|
{::rpc/auth true
|
||||||
|
::doc/added "2.14"
|
||||||
|
::sm/params [:map]
|
||||||
|
::sm/result schema:subscription-warning}
|
||||||
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
|
(nitrate/call cfg :get-subscription-warning {:profile-id profile-id}))
|
||||||
|
|
||||||
(def ^:private schema:redeem-activation-code-params
|
(def ^:private schema:redeem-activation-code-params
|
||||||
[:map {:title "RedeemActivationCodeParams"}
|
[:map {:title "RedeemActivationCodeParams"}
|
||||||
[:activation-code ::sm/text]])
|
[:activation-code ::sm/text]])
|
||||||
@ -110,12 +131,47 @@
|
|||||||
AND t.id = ANY(?)
|
AND t.id = ANY(?)
|
||||||
AND t.deleted_at IS NULL")
|
AND t.deleted_at IS NULL")
|
||||||
|
|
||||||
(def sql:get-team-files-count
|
(def ^:private sql:get-teams-files-counts
|
||||||
"SELECT count(*) AS total
|
"SELECT p.team_id, count(*) AS total
|
||||||
FROM file AS f
|
FROM file AS f
|
||||||
JOIN project AS p ON (p.id = f.project_id)
|
JOIN project AS p ON (p.id = f.project_id)
|
||||||
WHERE p.team_id = ?
|
WHERE p.team_id = ANY(?)
|
||||||
AND f.deleted_at IS NULL")
|
AND f.deleted_at IS NULL
|
||||||
|
GROUP BY p.team_id")
|
||||||
|
|
||||||
|
(defn- get-team-files-counts
|
||||||
|
[conn team-ids]
|
||||||
|
(if (seq team-ids)
|
||||||
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
|
(->> (db/exec! conn [sql:get-teams-files-counts ids-array])
|
||||||
|
(reduce (fn [acc {:keys [team-id total]}]
|
||||||
|
(assoc acc team-id (long total)))
|
||||||
|
{})))
|
||||||
|
{}))
|
||||||
|
|
||||||
|
(defn- build-leave-org-plan
|
||||||
|
[{:keys [::db/conn]} default-team-id teams-to-delete keep-default-team-requested?]
|
||||||
|
(let [all-teams (cond-> (set teams-to-delete) default-team-id (conj default-team-id))
|
||||||
|
files-counts (get-team-files-counts conn all-teams)
|
||||||
|
has-files? (fn [id] (pos? (long (get files-counts id 0))))
|
||||||
|
deletable (remove has-files? teams-to-delete)
|
||||||
|
keep-default? (or keep-default-team-requested?
|
||||||
|
(and default-team-id (has-files? default-team-id)))
|
||||||
|
to-detach (cond-> (into [] (remove (set deletable) teams-to-delete))
|
||||||
|
(and default-team-id keep-default?) (conj default-team-id))]
|
||||||
|
{:deletable-team-ids deletable
|
||||||
|
:keep-default-team? keep-default?
|
||||||
|
:delete-default-team? (boolean (and default-team-id (not keep-default?)))
|
||||||
|
:detach-from-org-team-ids to-detach}))
|
||||||
|
|
||||||
|
(defn get-leave-org-summary
|
||||||
|
[cfg default-team-id teams-to-delete teams-to-transfer-count teams-to-exit-count]
|
||||||
|
(let [{:keys [deletable-team-ids detach-from-org-team-ids]}
|
||||||
|
(build-leave-org-plan cfg default-team-id teams-to-delete nil)]
|
||||||
|
{:teams-to-delete (count deletable-team-ids)
|
||||||
|
:teams-to-transfer teams-to-transfer-count
|
||||||
|
:teams-to-exit teams-to-exit-count
|
||||||
|
:teams-to-detach (count detach-from-org-team-ids)}))
|
||||||
|
|
||||||
(def ^:private schema:leave-org
|
(def ^:private schema:leave-org
|
||||||
[:map
|
[:map
|
||||||
@ -130,6 +186,18 @@
|
|||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
[:reassign-to {:optional true} ::sm/uuid]]]]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-leave-org-summary-result
|
||||||
|
[:map
|
||||||
|
[:teams-to-delete ::sm/int]
|
||||||
|
[:teams-to-transfer ::sm/int]
|
||||||
|
[:teams-to-exit ::sm/int]
|
||||||
|
[:teams-to-detach ::sm/int]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-leave-org-summary
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:default-team-id ::sm/uuid]])
|
||||||
|
|
||||||
|
|
||||||
(defn- get-organization-teams-for-user
|
(defn- get-organization-teams-for-user
|
||||||
[{:keys [::db/conn] :as cfg} org-summary profile-id]
|
[{:keys [::db/conn] :as cfg} org-summary profile-id]
|
||||||
@ -219,16 +287,14 @@
|
|||||||
:code :not-valid-teams))))
|
:code :not-valid-teams))))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn leave-org
|
(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}]
|
[{:keys [::db/conn] :as cfg}
|
||||||
(let [org-prefix (str "[" (d/sanitize-string name) "] ")
|
{: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) "] ")
|
||||||
default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id])
|
{:keys [deletable-team-ids
|
||||||
:total)
|
keep-default-team?
|
||||||
delete-default-team? (= default-team-files-count 0)]
|
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
|
;; assert that the received teams are valid, checking the different constraints
|
||||||
(when-not skip-validation
|
(when-not skip-validation
|
||||||
@ -236,20 +302,27 @@
|
|||||||
|
|
||||||
(assert-membership cfg profile-id id)
|
(assert-membership cfg profile-id id)
|
||||||
|
|
||||||
;; delete the teams-to-delete
|
;; delete only eligible teams (non-protected and without files)
|
||||||
(doseq [id teams-to-delete]
|
(doseq [id deletable-team-ids]
|
||||||
(teams/delete-team cfg {:profile-id profile-id :team-id id}))
|
(teams/delete-team cfg {:profile-id profile-id
|
||||||
|
:team-id id}))
|
||||||
|
|
||||||
;; leave the teams-to-leave
|
;; leave the teams-to-leave
|
||||||
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
(doseq [{:keys [id reassign-to]} teams-to-leave]
|
||||||
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
(teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to}))
|
||||||
|
|
||||||
;; Delete default-team-id if empty; otherwise keep it and prefix the name.
|
;; Process org "Your Penpot" team: keep with prefix if needed, otherwise delete.
|
||||||
(if delete-default-team?
|
(when default-team-id
|
||||||
(do
|
(if keep-default-team?
|
||||||
(db/update! conn :team {:is-default false} {:id default-team-id})
|
(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}))
|
(teams/delete-team cfg {:profile-id profile-id
|
||||||
(db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-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
|
;; Api call to nitrate
|
||||||
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
(nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id})
|
||||||
@ -266,6 +339,25 @@
|
|||||||
(leave-org cfg (assoc params :profile-id profile-id)))
|
(leave-org cfg (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
|
||||||
|
(sv/defmethod ::get-leave-org-summary
|
||||||
|
{::rpc/auth true
|
||||||
|
::doc/added "2.18"
|
||||||
|
::sm/params schema:get-leave-org-summary
|
||||||
|
::sm/result schema:get-leave-org-summary-result
|
||||||
|
::db/transaction true}
|
||||||
|
[cfg {:keys [::rpc/profile-id id default-team-id]}]
|
||||||
|
(let [{:keys [valid-teams-to-delete-ids
|
||||||
|
valid-teams-to-transfer
|
||||||
|
valid-teams-to-exit
|
||||||
|
valid-default-team]} (get-valid-teams cfg id profile-id default-team-id)
|
||||||
|
teams-to-transfer-count (count valid-teams-to-transfer)
|
||||||
|
teams-to-exit-count (count valid-teams-to-exit)]
|
||||||
|
(when-not valid-default-team
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-valid-teams))
|
||||||
|
(get-leave-org-summary cfg default-team-id valid-teams-to-delete-ids teams-to-transfer-count teams-to-exit-count)))
|
||||||
|
|
||||||
|
|
||||||
(def ^:private schema:remove-team-from-org
|
(def ^:private schema:remove-team-from-org
|
||||||
[:map
|
[:map
|
||||||
[:team-id ::sm/uuid]
|
[:team-id ::sm/uuid]
|
||||||
@ -280,6 +372,20 @@
|
|||||||
(assert-is-owner cfg profile-id team-id)
|
(assert-is-owner cfg profile-id team-id)
|
||||||
(assert-not-default-team cfg team-id)
|
(assert-not-default-team cfg team-id)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(assert-membership cfg profile-id organization-id)
|
||||||
|
;; Check moveTeams permission on the source organization
|
||||||
|
(when (contains? cf/flags :nitrate)
|
||||||
|
(let [org-perms (nitrate/call cfg :get-org-permissions
|
||||||
|
{:organization-id organization-id})]
|
||||||
|
(if (nil? org-perms)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-allowed
|
||||||
|
:hint "Unable to verify organization permissions")
|
||||||
|
(when-not (nitrate-perms/allowed? :move-team
|
||||||
|
{:org-perms org-perms
|
||||||
|
:profile-id profile-id})
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-allowed
|
||||||
|
:hint "You are not allowed to move teams that are part of this organization. If you need more information, contact the owner.")))))
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Api call to nitrate
|
||||||
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
|
(nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id})
|
||||||
@ -288,6 +394,45 @@
|
|||||||
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
|
(notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org")
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
(def ^:private sql:get-team-invitation-emails
|
||||||
|
"SELECT email_to
|
||||||
|
FROM team_invitation
|
||||||
|
WHERE team_id = ?
|
||||||
|
AND valid_until > now()")
|
||||||
|
|
||||||
|
(def ^:private sql:delete-team-external-invitations
|
||||||
|
"DELETE FROM team_invitation
|
||||||
|
WHERE team_id = ?
|
||||||
|
AND email_to = ANY(?)
|
||||||
|
AND valid_until > now()")
|
||||||
|
|
||||||
|
(def ^:private sql:get-profiles-by-emails
|
||||||
|
"SELECT id, email
|
||||||
|
FROM profile
|
||||||
|
WHERE email = ANY(?)
|
||||||
|
AND deleted_at IS NULL")
|
||||||
|
|
||||||
|
(defn- get-external-invitation-info
|
||||||
|
"Returns info about external (non-org-member) invitations pending for a team.
|
||||||
|
External invitations are those sent to users who are not members of the given org.
|
||||||
|
Returns {:allows-anybody bool :external-emails [...]}"
|
||||||
|
[{:keys [::db/conn] :as cfg} team-id organization-id]
|
||||||
|
(let [org-perms (nitrate/call cfg :get-org-permissions {:organization-id organization-id})
|
||||||
|
allows-anybody (nitrate-perms/allowed? :add-anybody-to-team {:org-perms org-perms})]
|
||||||
|
(if allows-anybody
|
||||||
|
{:allows-anybody true :external-emails []}
|
||||||
|
(let [invitation-emails (db/exec! conn [sql:get-team-invitation-emails team-id])
|
||||||
|
emails (map :email-to invitation-emails)]
|
||||||
|
(if (empty? emails)
|
||||||
|
{:allows-anybody false :external-emails []}
|
||||||
|
(let [emails-array (db/create-array conn "text" (vec emails))
|
||||||
|
profiles (db/exec! conn [sql:get-profiles-by-emails emails-array])
|
||||||
|
org-member-ids (into #{} (nitrate/call cfg :get-org-members {:organization-id organization-id}))
|
||||||
|
external-emails (->> profiles
|
||||||
|
(remove #(contains? org-member-ids (:id %)))
|
||||||
|
(map :email)
|
||||||
|
(vec))]
|
||||||
|
{:allows-anybody false :external-emails external-emails}))))))
|
||||||
|
|
||||||
(def ^:private schema:add-team-to-organization
|
(def ^:private schema:add-team-to-organization
|
||||||
[:map
|
[:map
|
||||||
@ -305,15 +450,209 @@
|
|||||||
(assert-not-default-team cfg team-id)
|
(assert-not-default-team cfg team-id)
|
||||||
(assert-membership cfg profile-id organization-id)
|
(assert-membership cfg profile-id organization-id)
|
||||||
|
|
||||||
(let [team-members (db/query cfg :team-profile-rel {:team-id team-id})]
|
(when (contains? cf/flags :nitrate)
|
||||||
;; Add teammates to the org if needed
|
(let [team-with-org (nitrate/call cfg :get-team-org {:team-id team-id})
|
||||||
(doseq [{member-id :profile-id} team-members
|
source-org-id (get-in team-with-org [:organization :id])
|
||||||
:when (not= member-id profile-id)]
|
source-org-perms (when source-org-id
|
||||||
(teams/initialize-user-in-nitrate-org cfg member-id organization-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
|
;; Team already belongs to an organization: check move-teams on source org.
|
||||||
(let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})]
|
(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)
|
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}))
|
||||||
|
|||||||
@ -89,6 +89,12 @@
|
|||||||
email)]
|
email)]
|
||||||
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)
|
;; --- QUERY: Get profile (own)
|
||||||
|
|
||||||
|
|
||||||
@ -106,12 +112,12 @@
|
|||||||
(let [profile (-> (get-profile pool profile-id)
|
(let [profile (-> (get-profile pool profile-id)
|
||||||
(strip-private-attrs)
|
(strip-private-attrs)
|
||||||
(update :props filter-props))]
|
(update :props filter-props))]
|
||||||
(if (contains? cf/flags :nitrate)
|
(with-nitrate-licence profile cfg))
|
||||||
(nitrate/add-nitrate-licence-to-profile cfg profile)
|
|
||||||
profile))
|
|
||||||
|
|
||||||
(catch Throwable _
|
(catch Throwable cause
|
||||||
{:id uuid/zero :fullname "Anonymous User"})))
|
(if (= :not-found (-> cause ex-data :type))
|
||||||
|
{:id uuid/zero :fullname "Anonymous User"}
|
||||||
|
(throw cause)))))
|
||||||
|
|
||||||
(defn get-profile
|
(defn get-profile
|
||||||
"Get profile by id. Throws not-found exception if no profile found."
|
"Get profile by id. Throws not-found exception if no profile found."
|
||||||
@ -135,7 +141,7 @@
|
|||||||
::sm/params schema:update-profile
|
::sm/params schema:update-profile
|
||||||
::sm/result schema:profile
|
::sm/result schema:profile
|
||||||
::db/transaction true}
|
::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
|
;; NOTE: we need to retrieve the profile independently if we use
|
||||||
;; it or not for explicit locking and avoid concurrent updates of
|
;; it or not for explicit locking and avoid concurrent updates of
|
||||||
;; the same row/object.
|
;; the same row/object.
|
||||||
@ -156,6 +162,7 @@
|
|||||||
(-> profile
|
(-> profile
|
||||||
(strip-private-attrs)
|
(strip-private-attrs)
|
||||||
(d/without-nils)
|
(d/without-nils)
|
||||||
|
(with-nitrate-licence cfg)
|
||||||
(rph/with-meta {::audit/props (audit/profile->props profile)}))))
|
(rph/with-meta {::audit/props (audit/profile->props profile)}))))
|
||||||
|
|
||||||
|
|
||||||
@ -291,14 +298,14 @@
|
|||||||
:file-mtype (:mtype file)}}))))
|
:file-mtype (:mtype file)}}))))
|
||||||
|
|
||||||
(defn- generate-thumbnail
|
(defn- generate-thumbnail
|
||||||
[_ input]
|
[cfg input]
|
||||||
(let [input (media/run {:cmd :info :input input})
|
(let [input (media/run cfg {:cmd :info :input input})
|
||||||
thumb (media/run {:cmd :profile-thumbnail
|
thumb (media/run cfg {:cmd :profile-thumbnail
|
||||||
:format :jpeg
|
:format :jpeg
|
||||||
:quality 85
|
:quality 85
|
||||||
:width 256
|
:width 256
|
||||||
:height 256
|
:height 256
|
||||||
:input input})
|
:input input})
|
||||||
hash (sto/calculate-hash (:data thumb))
|
hash (sto/calculate-hash (:data thumb))
|
||||||
content (-> (sto/content (:data thumb) (:size thumb))
|
content (-> (sto/content (:data thumb) (:size thumb))
|
||||||
(sto/wrap-with-hash hash))]
|
(sto/wrap-with-hash hash))]
|
||||||
@ -483,8 +490,17 @@
|
|||||||
{:deleted-at deleted-at}
|
{:deleted-at deleted-at}
|
||||||
{:id profile-id})
|
{:id profile-id})
|
||||||
|
|
||||||
;; Api call to nitrate
|
;; Delete owned organizations on the fly (no grace period).
|
||||||
(nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id})
|
;; 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
|
;; Schedule cascade deletion to a worker
|
||||||
(wrk/submit! {::db/conn conn
|
(wrk/submit! {::db/conn conn
|
||||||
@ -493,7 +509,6 @@
|
|||||||
:deleted-at deleted-at
|
:deleted-at deleted-at
|
||||||
:id profile-id}})
|
:id profile-id}})
|
||||||
|
|
||||||
|
|
||||||
(-> (rph/wrap nil)
|
(-> (rph/wrap nil)
|
||||||
(rph/with-transform (session/delete-fn cfg)))))
|
(rph/with-transform (session/delete-fn cfg)))))
|
||||||
|
|
||||||
@ -520,6 +535,32 @@
|
|||||||
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
|
(let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])]
|
||||||
{:editors editors}))
|
{:editors editors}))
|
||||||
|
|
||||||
|
;; --- QUERY: Owned Organizations Summary (for delete-account modal)
|
||||||
|
|
||||||
|
(def ^:private schema:owned-organization-summary
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:slug ::sm/text]
|
||||||
|
[:team-count ::sm/int]
|
||||||
|
[:member-count ::sm/int]
|
||||||
|
[:avatar-bg-url {:optional true} [:maybe ::sm/uri]]
|
||||||
|
[:logo-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:custom-photo {:optional true} [:maybe ::sm/text]]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-owned-organizations-summary-result
|
||||||
|
[:vector schema:owned-organization-summary])
|
||||||
|
|
||||||
|
(sv/defmethod ::get-owned-organizations-summary
|
||||||
|
"List organizations owned by the current profile with team and member counts.
|
||||||
|
Used by the delete-account modal to warn the user about cascading deletion."
|
||||||
|
{::doc/added "2.18"
|
||||||
|
::sm/result schema:get-owned-organizations-summary-result}
|
||||||
|
[cfg {:keys [::rpc/profile-id]}]
|
||||||
|
(if (contains? cf/flags :nitrate)
|
||||||
|
(or (nitrate/call cfg :get-owned-orgs-summary {:profile-id profile-id}) [])
|
||||||
|
[]))
|
||||||
|
|
||||||
;; --- HELPERS
|
;; --- HELPERS
|
||||||
|
|
||||||
(def sql:owned-teams
|
(def sql:owned-teams
|
||||||
|
|||||||
@ -157,6 +157,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-project
|
(sv/defmethod ::get-project
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
|
::rpc/id-type :project
|
||||||
::sm/params schema:get-project}
|
::sm/params schema:get-project}
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
|
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
|
||||||
(dm/with-open [conn (db/open pool)]
|
(dm/with-open [conn (db/open pool)]
|
||||||
@ -223,6 +224,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::update-project-pin
|
(sv/defmethod ::update-project-pin
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
|
::rpc/id-type :project
|
||||||
::sm/params schema:update-project-pin
|
::sm/params schema:update-project-pin
|
||||||
::webhooks/batch-timeout (ct/duration "5s")
|
::webhooks/batch-timeout (ct/duration "5s")
|
||||||
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
||||||
@ -244,6 +246,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::rename-project
|
(sv/defmethod ::rename-project
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
|
::rpc/id-type :project
|
||||||
::sm/params schema:rename-project
|
::sm/params schema:rename-project
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
@ -286,6 +289,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::delete-project
|
(sv/defmethod ::delete-project
|
||||||
{::doc/added "1.18"
|
{::doc/added "1.18"
|
||||||
|
::rpc/id-type :project
|
||||||
::sm/params schema:delete-project
|
::sm/params schema:delete-project
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -193,7 +194,9 @@
|
|||||||
(dm/with-open [conn (db/open pool)]
|
(dm/with-open [conn (db/open pool)]
|
||||||
(cond->> (get-teams conn profile-id)
|
(cond->> (get-teams conn profile-id)
|
||||||
(contains? cf/flags :nitrate)
|
(contains? cf/flags :nitrate)
|
||||||
(map #(nitrate/add-org-info-to-team cfg % params)))))
|
(map #(nitrate/add-org-info-to-team cfg % params))
|
||||||
|
(contains? cf/flags :nitrate)
|
||||||
|
(remove #(get-in % [:organization :expired-license])))))
|
||||||
|
|
||||||
(def ^:private sql:get-owned-teams
|
(def ^:private sql:get-owned-teams
|
||||||
"SELECT t.id, t.name,
|
"SELECT t.id, t.name,
|
||||||
@ -233,6 +236,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::get-team
|
(sv/defmethod ::get-team
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :team
|
||||||
::sm/params schema:get-team}
|
::sm/params schema:get-team}
|
||||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
|
[{:keys [::db/pool]} {:keys [::rpc/profile-id id file-id]}]
|
||||||
(get-team pool :profile-id profile-id :team-id id :file-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
|
(sv/defmethod ::create-team
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
::sm/params schema:create-team}
|
::sm/params schema:create-team}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id organization-id] :as params}]
|
||||||
|
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
(quotes/check! cfg {::quotes/id ::quotes/teams-per-profile
|
||||||
::quotes/profile-id profile-id})
|
::quotes/profile-id profile-id})
|
||||||
|
|
||||||
|
;; When creating inside an org, verify the user has permission to do so.
|
||||||
|
;; Fail closed: if org permissions cannot be fetched, deny the operation.
|
||||||
|
(when (and organization-id (contains? cf/flags :nitrate))
|
||||||
|
(let [org-perms (nitrate/call cfg :get-org-permissions
|
||||||
|
{:organization-id organization-id})]
|
||||||
|
(if (nil? org-perms)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-allowed
|
||||||
|
:hint "Unable to verify organization permissions")
|
||||||
|
(when-not (nitrate-perms/allowed? :create-team
|
||||||
|
{:org-perms org-perms
|
||||||
|
:profile-id profile-id})
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :not-allowed
|
||||||
|
:hint "You are not allowed to create teams in this organization")))))
|
||||||
|
|
||||||
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
(let [features (-> (cfeat/get-enabled-features cf/flags)
|
||||||
(set/difference cfeat/frontend-only-features)
|
(set/difference cfeat/frontend-only-features)
|
||||||
(set/difference cfeat/no-team-inheritable-features))
|
(set/difference cfeat/no-team-inheritable-features))
|
||||||
@ -672,6 +692,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::update-team
|
(sv/defmethod ::update-team
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :team
|
||||||
::sm/params schema:update-team
|
::sm/params schema:update-team
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
|
||||||
@ -747,6 +768,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::leave-team
|
(sv/defmethod ::leave-team
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :team
|
||||||
::sm/params schema:leave-team
|
::sm/params schema:leave-team
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
@ -757,16 +779,31 @@
|
|||||||
|
|
||||||
(defn delete-team
|
(defn delete-team
|
||||||
"Mark a team for deletion"
|
"Mark a team for deletion"
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}]
|
[{: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)
|
(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
|
(ex/raise :type :validation
|
||||||
:code :only-owner-can-delete-team))
|
: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
|
(ex/raise :type :validation
|
||||||
:code :non-deletable-team
|
:code :non-deletable-team
|
||||||
:hint "impossible to delete default team"))
|
:hint "impossible to delete default team"))
|
||||||
@ -794,6 +831,7 @@
|
|||||||
|
|
||||||
(sv/defmethod ::delete-team
|
(sv/defmethod ::delete-team
|
||||||
{::doc/added "1.17"
|
{::doc/added "1.17"
|
||||||
|
::rpc/id-type :team
|
||||||
::sm/params schema:delete-team
|
::sm/params schema:delete-team
|
||||||
::db/transaction true}
|
::db/transaction true}
|
||||||
[cfg {:keys [::rpc/profile-id id] :as params}]
|
[cfg {:keys [::rpc/profile-id id] :as params}]
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.common.types.nitrate-permissions :as nitrate-perms]
|
||||||
[app.common.types.team :as types.team]
|
[app.common.types.team :as types.team]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
@ -112,8 +113,19 @@
|
|||||||
(let [notifications (dm/get-in member [:props :notifications])]
|
(let [notifications (dm/get-in member [:props :notifications])]
|
||||||
(not= :none (:email-invites notifications))))
|
(not= :none (:email-invites notifications))))
|
||||||
|
|
||||||
|
(defn- assert-email-can-be-invited
|
||||||
|
"Asserts that member is an org member when the org
|
||||||
|
restricts who can be added to teams."
|
||||||
|
[member org-member-ids]
|
||||||
|
(when (some? org-member-ids)
|
||||||
|
(let [is-member? (and (some? member) (contains? org-member-ids (:id member)))]
|
||||||
|
(when-not is-member?
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :email-not-org-member
|
||||||
|
:hint "The invited email is not a member of the organization")))))
|
||||||
|
|
||||||
(defn- create-invitation
|
(defn- create-invitation
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [team organization profile role email org-member-ids] :as params}]
|
||||||
|
|
||||||
(assert (db/connection-map? cfg)
|
(assert (db/connection-map? cfg)
|
||||||
"expected cfg with valid connection")
|
"expected cfg with valid connection")
|
||||||
@ -130,6 +142,13 @@
|
|||||||
:code :email-domain-is-not-allowed
|
:code :email-domain-is-not-allowed
|
||||||
:hint "email domain is in the blacklist"))
|
:hint "email domain is in the blacklist"))
|
||||||
|
|
||||||
|
;; When nitrate is active and the team belongs to an org, check that
|
||||||
|
;; the email is already an org member unless the org explicitly allows adding anybody.
|
||||||
|
(when (and (contains? cf/flags :nitrate)
|
||||||
|
(:organization team))
|
||||||
|
(assert-email-can-be-invited member org-member-ids))
|
||||||
|
|
||||||
|
|
||||||
;; When we have email verification disabled and invitation user is
|
;; When we have email verification disabled and invitation user is
|
||||||
;; already present in the database, we proceed to add it to the
|
;; already present in the database, we proceed to add it to the
|
||||||
;; team as-is, without email roundtrip.
|
;; team as-is, without email roundtrip.
|
||||||
@ -218,32 +237,26 @@
|
|||||||
:to email
|
:to email
|
||||||
:invited-by (:fullname profile)
|
:invited-by (:fullname profile)
|
||||||
:user-name (:fullname member)
|
:user-name (:fullname member)
|
||||||
:organization-name (:name organization)
|
:organization organization
|
||||||
:organization-logo (:logo organization)
|
|
||||||
:organization-initials (:initials organization)
|
|
||||||
:token itoken
|
:token itoken
|
||||||
:extra-data ptoken}))
|
:extra-data ptoken}))
|
||||||
(let [team (if (contains? cf/flags :nitrate)
|
(eml/send! {::eml/conn conn
|
||||||
(nitrate/add-org-info-to-team cfg team {})
|
::eml/factory eml/invite-to-team
|
||||||
team)]
|
:public-uri (cf/get :public-uri)
|
||||||
(eml/send! {::eml/conn conn
|
:to email
|
||||||
::eml/factory eml/invite-to-team
|
:invited-by (:fullname profile)
|
||||||
:public-uri (cf/get :public-uri)
|
:team (:name team)
|
||||||
:to email
|
:organization (dm/get-in team [:organization :name])
|
||||||
:invited-by (:fullname profile)
|
:token itoken
|
||||||
:team (:name team)
|
:extra-data ptoken})))
|
||||||
:organization (:organization-name team)
|
|
||||||
:token itoken
|
|
||||||
:extra-data ptoken}))))
|
|
||||||
|
|
||||||
itoken)))))
|
itoken)))))
|
||||||
|
|
||||||
(defn create-org-invitation
|
(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)]
|
(let [profile (db/get-by-id cfg :profile profile-id)]
|
||||||
(create-invitation cfg
|
(create-invitation cfg
|
||||||
(assoc params
|
(assoc params
|
||||||
:organization {:id id :name name :initials initials :logo logo}
|
|
||||||
:profile profile
|
:profile profile
|
||||||
:role :editor))))
|
:role :editor))))
|
||||||
|
|
||||||
@ -309,7 +322,18 @@
|
|||||||
- emails (set) + role (single role for all emails)
|
- emails (set) + role (single role for all emails)
|
||||||
- invitations (vector of {:email :role} maps)"
|
- invitations (vector of {:email :role} maps)"
|
||||||
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
[{:keys [::db/conn] :as cfg} {:keys [profile team role emails invitations] :as params}]
|
||||||
(let [;; 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
|
invitation-data (cond
|
||||||
;; Case 1: emails + single role (create invitations style)
|
;; Case 1: emails + single role (create invitations style)
|
||||||
(and emails role)
|
(and emails role)
|
||||||
|
|||||||
@ -185,7 +185,10 @@
|
|||||||
registration-disabled? (not (contains? cf/flags :registration))
|
registration-disabled? (not (contains? cf/flags :registration))
|
||||||
|
|
||||||
org-invitation? (and (contains? cf/flags :nitrate) organization-id)
|
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
|
(nitrate/call cfg :get-org-membership {:profile-id profile-id
|
||||||
:organization-id organization-id}))]
|
:organization-id organization-id}))]
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
of the object. This function can be applied to the object returned by the
|
of the object. This function can be applied to the object returned by the
|
||||||
`get-object` but also to the RPC return value (in case you don't provide
|
`get-object` but also to the RPC return value (in case you don't provide
|
||||||
the return value calculated key under `::key` metadata prop.
|
the return value calculated key under `::key` metadata prop.
|
||||||
- `::reuse-key?` enables reusing the key calculated on first time; 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
|
when the target object is not retrieved on the RPC (typical on retrieving
|
||||||
dependent objects).
|
dependent objects).
|
||||||
"
|
"
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
data (-> (sto/content (:path content))
|
data (-> (sto/content (:path content))
|
||||||
(sto/wrap-with-hash hash))
|
(sto/wrap-with-hash hash))
|
||||||
content {::sto/content data
|
content {::sto/content data
|
||||||
::sto/deduplicate? true
|
::sto/deduplicate? false
|
||||||
::sto/touched-at (ct/in-future {:minutes 10})
|
::sto/touched-at (ct/in-future {:minutes 10})
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:content-type (:mtype content)
|
:content-type (:mtype content)
|
||||||
|
|||||||
@ -12,14 +12,17 @@
|
|||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.types.organization :refer [schema:team-with-organization]]
|
[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.profile :refer [schema:profile, schema:basic-profile]]
|
||||||
[app.common.types.team :refer [schema:team]]
|
[app.common.types.team :refer [schema:team]]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
|
[app.email :as eml]
|
||||||
|
[app.http.session :as session]
|
||||||
|
[app.loggers.audit :as audit]
|
||||||
[app.media :as media]
|
[app.media :as media]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as rpc]
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.commands.nitrate :as cnit]
|
[app.rpc.commands.nitrate :as cnit]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
@ -29,7 +32,8 @@
|
|||||||
[app.rpc.notifications :as notifications]
|
[app.rpc.notifications :as notifications]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
[app.worker :as wrk]))
|
[app.worker :as wrk]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
|
|
||||||
(defn- profile-to-map [profile]
|
(defn- profile-to-map [profile]
|
||||||
@ -48,7 +52,8 @@
|
|||||||
[cfg {:keys [::rpc/profile-id] :as params}]
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
(let [profile (profile/get-profile cfg profile-id)]
|
(let [profile (profile/get-profile cfg profile-id)]
|
||||||
(-> (profile-to-map profile)
|
(-> (profile-to-map profile)
|
||||||
(assoc :theme (:theme profile)))))
|
(assoc :theme (:theme profile))
|
||||||
|
(assoc :lang (:lang profile)))))
|
||||||
|
|
||||||
;; ---- API: get-teams
|
;; ---- API: get-teams
|
||||||
|
|
||||||
@ -296,46 +301,61 @@ RETURNING id, deleted_at;")
|
|||||||
nil)
|
nil)
|
||||||
|
|
||||||
(defn manage-deleted-organization-teams
|
(defn manage-deleted-organization-teams
|
||||||
"For a list of teams, rename those with files and delete those without, then notify users."
|
"For a deleted organization, preserve org teams unchanged and only prefix or
|
||||||
[cfg {:keys [teams organization-name]}]
|
delete member Your Penpot teams depending on whether they still contain files."
|
||||||
(let [teams (->> teams (filter uuid?) distinct (into []))]
|
[cfg {:keys [organization-id organization-name teams]}]
|
||||||
(when (seq 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) "] ")]
|
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||||
(db/tx-run!
|
(db/tx-run!
|
||||||
cfg
|
cfg
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
(let [teams-array (db/create-array conn "uuid" teams)
|
(let [teams-with-files (if (seq your-penpot-team-ids)
|
||||||
teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array])
|
(->> (db/exec! conn [sql:get-teams-files-counts
|
||||||
(filter (fn [{:keys [total]}] (pos? total)))
|
(db/create-array conn "uuid" your-penpot-team-ids)])
|
||||||
(map :team-id)
|
(filter (fn [{:keys [total]}] (pos? total)))
|
||||||
(into #{}))
|
(map :team-id)
|
||||||
teams-to-keep (->> teams (filter teams-with-files) (into []))
|
(into #{}))
|
||||||
teams-to-delete (->> teams (remove teams-with-files) (into []))]
|
#{})
|
||||||
|
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
|
||||||
|
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
|
||||||
|
|
||||||
;; Rename teams that have files in one go
|
;; Org teams move to the fallback org unchanged. Only imported
|
||||||
(when (seq teams-to-keep)
|
;; 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
|
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||||
org-prefix
|
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)
|
(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)))))))
|
nil)))))))
|
||||||
|
|
||||||
|
|
||||||
(sv/defmethod ::notify-organization-deletion
|
(sv/defmethod ::notify-organization-deletion
|
||||||
"For a list of teams, rename them with the name of the deleted org, and notify
|
"For a deleted organization, preserve org teams and only prefix or delete
|
||||||
of the deletion to the connected users"
|
imported Your Penpot teams before notifying connected users."
|
||||||
{::doc/added "2.15"
|
{::doc/added "2.15"
|
||||||
::sm/params schema:notify-organization-deletion
|
::sm/params schema:notify-organization-deletion
|
||||||
::rpc/auth false}
|
::rpc/auth false}
|
||||||
[cfg {:keys [organization-id]}]
|
[cfg {:keys [organization-id]}]
|
||||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||||
teams (->> (:teams org-summary)
|
teams (:teams org-summary)]
|
||||||
(map :id))]
|
(manage-deleted-organization-teams cfg {:organization-name (:name org-summary)
|
||||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)})
|
:organization-id (:id org-summary)
|
||||||
|
:teams teams})
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
;; ---- API: notify-user-organizations-deletion
|
;; ---- API: notify-user-organizations-deletion
|
||||||
@ -345,15 +365,18 @@ RETURNING id, deleted_at;")
|
|||||||
[:profile-id ::sm/uuid]])
|
[:profile-id ::sm/uuid]])
|
||||||
|
|
||||||
(sv/defmethod ::notify-user-organizations-deletion
|
(sv/defmethod ::notify-user-organizations-deletion
|
||||||
"For a given user, find all owned organizations and 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"
|
{::doc/added "2.18"
|
||||||
::sm/params schema:notify-user-organizations-deletion}
|
::sm/params schema:notify-user-organizations-deletion}
|
||||||
[cfg {:keys [profile-id]}]
|
[cfg {:keys [profile-id]}]
|
||||||
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
||||||
(doseq [org owned-orgs]
|
(doseq [org owned-orgs]
|
||||||
(let [organization-name (:name org)
|
(let [organization-name (:name org)
|
||||||
teams (map :id (:teams org))]
|
teams (:teams org)]
|
||||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name}))))
|
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
||||||
|
:organization-id (:id org)
|
||||||
|
:teams teams}))))
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
|
||||||
@ -454,10 +477,7 @@ RETURNING id, deleted_at;")
|
|||||||
{::doc/added "2.15"
|
{::doc/added "2.15"
|
||||||
::sm/params [:map
|
::sm/params [:map
|
||||||
[:email ::sm/email]
|
[:email ::sm/email]
|
||||||
[:id ::sm/uuid]
|
[:organization schema:organization-with-avatar]]}
|
||||||
[:name ::sm/text]
|
|
||||||
[:initials [:maybe :string]]
|
|
||||||
[:logo ::sm/uri]]}
|
|
||||||
[cfg params]
|
[cfg params]
|
||||||
(db/tx-run! cfg ti/create-org-invitation params)
|
(db/tx-run! cfg ti/create-org-invitation params)
|
||||||
nil)
|
nil)
|
||||||
@ -472,6 +492,7 @@ RETURNING id, deleted_at;")
|
|||||||
ti.email_to AS email,
|
ti.email_to AS email,
|
||||||
ti.created_at AS sent_at,
|
ti.created_at AS sent_at,
|
||||||
p.fullname AS name,
|
p.fullname AS name,
|
||||||
|
p.id AS profile_id,
|
||||||
p.photo_id
|
p.photo_id
|
||||||
FROM team_invitation AS ti
|
FROM team_invitation AS ti
|
||||||
LEFT JOIN profile AS p
|
LEFT JOIN profile AS p
|
||||||
@ -493,6 +514,7 @@ LEFT JOIN profile AS p
|
|||||||
[:email ::sm/email]
|
[:email ::sm/email]
|
||||||
[:sent-at ::sm/inst]
|
[:sent-at ::sm/inst]
|
||||||
[:name {:optional true} [:maybe ::sm/text]]
|
[:name {:optional true} [:maybe ::sm/text]]
|
||||||
|
[:profile-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
[:photo-url {:optional true} ::sm/uri]]])
|
[:photo-url {:optional true} ::sm/uri]]])
|
||||||
|
|
||||||
(sv/defmethod ::get-org-invitations
|
(sv/defmethod ::get-org-invitations
|
||||||
@ -544,6 +566,33 @@ LEFT JOIN profile AS p
|
|||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|
||||||
|
;; API: delete-all-org-invitations
|
||||||
|
|
||||||
|
(def ^:private sql:delete-all-org-invitations
|
||||||
|
"DELETE FROM team_invitation AS ti
|
||||||
|
WHERE ti.org_id = ?
|
||||||
|
OR ti.team_id = ANY(?);")
|
||||||
|
|
||||||
|
(def ^:private schema:delete-all-org-invitations-params
|
||||||
|
[:map
|
||||||
|
[:organization-id ::sm/uuid]])
|
||||||
|
|
||||||
|
(sv/defmethod ::delete-all-org-invitations
|
||||||
|
"Delete every pending invitation associated with an organization (org-level + team-level).
|
||||||
|
Called from Nitrate when an organization is about to be deleted, so users that click
|
||||||
|
their invitation token hit the existing invalid-token landing page."
|
||||||
|
{::doc/added "2.18"
|
||||||
|
::sm/params schema:delete-all-org-invitations-params
|
||||||
|
::rpc/auth false}
|
||||||
|
[cfg {:keys [organization-id]}]
|
||||||
|
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||||
|
team-ids (->> (:teams org-summary)
|
||||||
|
(map :id))]
|
||||||
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
||||||
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
||||||
|
(db/exec! conn [sql:delete-all-org-invitations organization-id ids-array]))))
|
||||||
|
nil))
|
||||||
|
|
||||||
|
|
||||||
;; API: remove-from-org
|
;; API: remove-from-org
|
||||||
|
|
||||||
@ -603,7 +652,8 @@ LEFT JOIN profile AS p
|
|||||||
[:map
|
[:map
|
||||||
[:teams-to-delete ::sm/int]
|
[:teams-to-delete ::sm/int]
|
||||||
[:teams-to-transfer ::sm/int]
|
[:teams-to-transfer ::sm/int]
|
||||||
[:teams-to-exit ::sm/int]])
|
[:teams-to-exit ::sm/int]
|
||||||
|
[:teams-to-detach ::sm/int]])
|
||||||
|
|
||||||
(sv/defmethod ::get-remove-from-org-summary
|
(sv/defmethod ::get-remove-from-org-summary
|
||||||
"Get a summary of the teams that would be deleted, transferred, or exited
|
"Get a summary of the teams that would be deleted, transferred, or exited
|
||||||
@ -623,7 +673,170 @@ LEFT JOIN profile AS p
|
|||||||
(when-not valid-default-team
|
(when-not valid-default-team
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :not-valid-teams))
|
:code :not-valid-teams))
|
||||||
{:teams-to-delete (count valid-teams-to-delete-ids)
|
(cnit/get-leave-org-summary cfg
|
||||||
:teams-to-transfer (count valid-teams-to-transfer)
|
default-team-id
|
||||||
:teams-to-exit (count valid-teams-to-exit)}))
|
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)
|
||||||
|
|||||||
@ -34,11 +34,20 @@
|
|||||||
|
|
||||||
|
|
||||||
(defn notify-organization-deletion
|
(defn notify-organization-deletion
|
||||||
[cfg organization-name teams deleted-teams]
|
[cfg organization-id organization-name teams deleted-teams]
|
||||||
(let [msgbus (::mbus/msgbus cfg)]
|
(let [msgbus (::mbus/msgbus cfg)]
|
||||||
(mbus/pub! msgbus
|
(mbus/pub! msgbus
|
||||||
:topic uuid/zero
|
:topic uuid/zero
|
||||||
:message {:type :organization-deleted
|
:message {:type :organization-deleted
|
||||||
|
:organization-id organization-id
|
||||||
:organization-name organization-name
|
:organization-name organization-name
|
||||||
:teams teams
|
:teams teams
|
||||||
:deleted-teams deleted-teams})))
|
:deleted-teams deleted-teams})))
|
||||||
|
|
||||||
|
(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})))
|
||||||
|
|||||||
@ -57,9 +57,9 @@
|
|||||||
|
|
||||||
(if (fs/exists? path)
|
(if (fs/exists? path)
|
||||||
(io/input-stream path)
|
(io/input-stream path)
|
||||||
(let [resp (http/req cfg
|
(let [resp (http/req-with-redirects cfg
|
||||||
{:method :get :uri (:file-uri template)}
|
{:method :get :uri (:file-uri template)}
|
||||||
{:response-type :input-stream :sync? true})]
|
{:response-type :input-stream :sync? true})]
|
||||||
(when-not (= 200 (:status resp))
|
(when-not (= 200 (:status resp))
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :unexpected-status-code
|
:code :unexpected-status-code
|
||||||
|
|||||||
@ -135,7 +135,8 @@
|
|||||||
;; still not deleted.
|
;; still not deleted.
|
||||||
result (when (and (::deduplicate? params)
|
result (when (and (::deduplicate? params)
|
||||||
(:hash mdata)
|
(:hash mdata)
|
||||||
(:bucket mdata))
|
(:bucket mdata)
|
||||||
|
(not= "tempfile" (:bucket mdata)))
|
||||||
(let [result (get-database-object-by-hash connectable backend
|
(let [result (get-database-object-by-hash connectable backend
|
||||||
(:bucket mdata)
|
(:bucket mdata)
|
||||||
(:hash mdata))]
|
(:hash mdata))]
|
||||||
|
|||||||
@ -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!))))
|
|
||||||
@ -12,7 +12,6 @@
|
|||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[promesa.exec :as px])
|
[promesa.exec :as px])
|
||||||
(:import
|
(:import
|
||||||
com.github.benmanes.caffeine.cache.AsyncCache
|
|
||||||
com.github.benmanes.caffeine.cache.Cache
|
com.github.benmanes.caffeine.cache.Cache
|
||||||
com.github.benmanes.caffeine.cache.Caffeine
|
com.github.benmanes.caffeine.cache.Caffeine
|
||||||
com.github.benmanes.caffeine.cache.RemovalListener
|
com.github.benmanes.caffeine.cache.RemovalListener
|
||||||
@ -25,7 +24,8 @@
|
|||||||
|
|
||||||
(defprotocol ICache
|
(defprotocol ICache
|
||||||
(get [_ k] [_ k load-fn] "get cache entry")
|
(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
|
(defprotocol ICacheStats
|
||||||
(stats [_] "get stats"))
|
(stats [_] "get stats"))
|
||||||
@ -47,15 +47,18 @@
|
|||||||
:miss-rate (.missRate stats)}))
|
:miss-rate (.missRate stats)}))
|
||||||
|
|
||||||
(defn create
|
(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
|
(let [cache (as-> (Caffeine/newBuilder) builder
|
||||||
(if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder)
|
(if (fn? on-remove) (.removalListener builder (create-listener on-remove)) builder)
|
||||||
(if executor (.executor builder ^Executor (px/resolve-executor executor)) builder)
|
(if executor (.executor builder ^Executor (px/resolve-executor executor)) builder)
|
||||||
(if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) builder)
|
(if keepalive (.expireAfterAccess builder ^Duration (ct/duration keepalive)) builder)
|
||||||
|
(if expire (.expireAfterWrite builder ^Duration (ct/duration expire)) builder)
|
||||||
(if (int? max-size) (.maximumSize builder (long max-size)) builder)
|
(if (int? max-size) (.maximumSize builder (long max-size)) builder)
|
||||||
(.recordStats builder)
|
(.recordStats builder)
|
||||||
(.buildAsync builder))
|
(.build builder))]
|
||||||
cache (.synchronous ^AsyncCache cache)]
|
|
||||||
(reify
|
(reify
|
||||||
ICache
|
ICache
|
||||||
(get [_ k]
|
(get [_ k]
|
||||||
@ -66,10 +69,14 @@
|
|||||||
^Function (reify Function
|
^Function (reify Function
|
||||||
(apply [_ k]
|
(apply [_ k]
|
||||||
(load-fn k)))))
|
(load-fn k)))))
|
||||||
(invalidate! [_]
|
(invalidate [_]
|
||||||
(.invalidateAll ^Cache cache))
|
(.invalidateAll ^Cache cache))
|
||||||
(invalidate! [_ k]
|
(invalidate [_ k]
|
||||||
(.invalidateAll ^Cache cache ^Object k))
|
(.invalidate ^Cache cache ^Object k))
|
||||||
|
(invalidate-if [_ pred]
|
||||||
|
(doseq [[k v] (.asMap ^Cache cache)]
|
||||||
|
(when (pred v)
|
||||||
|
(.invalidate ^Cache cache ^Object k))))
|
||||||
|
|
||||||
ICacheStats
|
ICacheStats
|
||||||
(stats [_]
|
(stats [_]
|
||||||
|
|||||||
@ -8,17 +8,32 @@
|
|||||||
"A penpot specific, modern api for executing external (shell)
|
"A penpot specific, modern api for executing external (shell)
|
||||||
subprocesses"
|
subprocesses"
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
[app.worker :as-alias wrk]
|
[app.worker :as-alias wrk]
|
||||||
[datoteka.io :as io]
|
[datoteka.io :as io]
|
||||||
[promesa.exec :as px])
|
[promesa.exec :as px])
|
||||||
(:import
|
(:import
|
||||||
java.io.InputStream
|
java.io.InputStream
|
||||||
java.io.OutputStream
|
java.io.OutputStream
|
||||||
|
java.util.concurrent.TimeUnit
|
||||||
java.util.List
|
java.util.List
|
||||||
org.apache.commons.io.IOUtils))
|
org.apache.commons.io.IOUtils))
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(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
|
(defn- read-as-bytes
|
||||||
[in]
|
[in]
|
||||||
(with-open [^InputStream input (io/input-stream in)]
|
(with-open [^InputStream input (io/input-stream in)]
|
||||||
@ -39,17 +54,22 @@
|
|||||||
[penv k v]
|
[penv k v]
|
||||||
(.put ^java.util.Map penv
|
(.put ^java.util.Map penv
|
||||||
^String k
|
^String k
|
||||||
^String v))
|
^String v)
|
||||||
|
penv)
|
||||||
|
|
||||||
(defn exec!
|
(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"
|
:or {out-enc "UTF-8"
|
||||||
in-enc "UTF-8"}}]
|
in-enc "UTF-8"}}]
|
||||||
(assert (vector? cmd) "a command parameter should be a vector")
|
(assert (vector? cmd) "a command parameter should be a vector")
|
||||||
(assert (every? string? cmd) "the command should be a vector of strings")
|
(assert (every? string? cmd) "the command should be a vector of strings")
|
||||||
|
|
||||||
(let [executor (::wrk/executor system)
|
(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)
|
env-map (.environment ^ProcessBuilder builder)
|
||||||
_ (reduce-kv set-env env-map env)
|
_ (reduce-kv set-env env-map env)
|
||||||
process (.start builder)]
|
process (.start builder)]
|
||||||
@ -63,9 +83,22 @@
|
|||||||
|
|
||||||
(with-open [stdout (.getInputStream ^Process process)
|
(with-open [stdout (.getInputStream ^Process process)
|
||||||
stderr (.getErrorStream ^Process process)]
|
stderr (.getErrorStream ^Process process)]
|
||||||
(let [out (px/submit! executor (fn [] (read-with-enc stdout out-enc)))
|
(let [out (px/submit! executor (fn [] (try (read-with-enc stdout out-enc)
|
||||||
err (px/submit! executor (fn [] (read-as-string stderr)))
|
(catch java.io.IOException _ ""))))
|
||||||
ext (.waitFor ^Process process)]
|
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
|
{:exit ext
|
||||||
:out @out
|
:out @out
|
||||||
:err @err}))))
|
:err @err}))))
|
||||||
|
|||||||
@ -15,9 +15,7 @@
|
|||||||
[promesa.exec :as px])
|
[promesa.exec :as px])
|
||||||
(:import
|
(:import
|
||||||
io.netty.channel.nio.NioEventLoopGroup
|
io.netty.channel.nio.NioEventLoopGroup
|
||||||
io.netty.util.concurrent.DefaultEventExecutorGroup
|
|
||||||
java.util.concurrent.ExecutorService
|
java.util.concurrent.ExecutorService
|
||||||
java.util.concurrent.ThreadFactory
|
|
||||||
java.util.concurrent.TimeUnit))
|
java.util.concurrent.TimeUnit))
|
||||||
|
|
||||||
(set! *warn-on-reflection* true)
|
(set! *warn-on-reflection* true)
|
||||||
@ -36,13 +34,6 @@
|
|||||||
{:title "executor"
|
{:title "executor"
|
||||||
:description "Instance of NioEventLoopGroup"}})
|
:description "Instance of NioEventLoopGroup"}})
|
||||||
|
|
||||||
(sm/register!
|
|
||||||
{:type ::wrk/netty-executor
|
|
||||||
:pred #(instance? DefaultEventExecutorGroup %)
|
|
||||||
:type-properties
|
|
||||||
{:title "executor"
|
|
||||||
:description "Instance of DefaultEventExecutorGroup"}})
|
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; IO Executor
|
;; IO Executor
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -58,7 +49,7 @@
|
|||||||
nthreads (or threads (mth/round (/ (px/get-available-processors) 2)))
|
nthreads (or threads (mth/round (/ (px/get-available-processors) 2)))
|
||||||
nthreads (max 2 nthreads)]
|
nthreads (max 2 nthreads)]
|
||||||
(l/inf :hint "start netty io executor" :threads 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
|
(defmethod ig/halt-key! ::wrk/netty-io-executor
|
||||||
[_ instance]
|
[_ instance]
|
||||||
@ -68,22 +59,15 @@
|
|||||||
TimeUnit/MILLISECONDS)))
|
TimeUnit/MILLISECONDS)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; IO Offload Executor
|
;; Executor
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defmethod ig/assert-key ::wrk/netty-executor
|
(defmethod ig/init-key ::wrk/executor
|
||||||
[_ {:keys [threads]}]
|
[_ _]
|
||||||
(assert (or (nil? threads) (int? threads))
|
(let [factory (px/thread-factory :prefix "penpot/exec/")]
|
||||||
"expected valid threads value, revisit PENPOT_EXEC_THREADS environment variable"))
|
(l/inf :hint "start cached executor")
|
||||||
|
(px/cached-executor :factory factory)))
|
||||||
|
|
||||||
(defmethod ig/init-key ::wrk/netty-executor
|
(defmethod ig/halt-key! ::wrk/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
|
|
||||||
[_ instance]
|
[_ instance]
|
||||||
(px/shutdown! instance))
|
(px/shutdown! instance))
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
[app.rpc.commands.access-token]
|
[app.rpc.commands.access-token]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
[backend-tests.helpers :as th]
|
[backend-tests.helpers :as th]
|
||||||
|
[clojure.string :as str]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[mockery.core :refer [with-mocks]]
|
[mockery.core :refer [with-mocks]]
|
||||||
[yetti.request :as yreq]
|
[yetti.request :as yreq]
|
||||||
@ -112,6 +113,74 @@
|
|||||||
(t/is (= #{} (:app.http.access-token/perms response)))
|
(t/is (= #{} (:app.http.access-token/perms response)))
|
||||||
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
|
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
|
||||||
|
|
||||||
|
(defrecord MethodAwareDummyRequest [req-method headers]
|
||||||
|
yreq/IRequest
|
||||||
|
(method [_] req-method)
|
||||||
|
(get-header [_ name] (get headers name)))
|
||||||
|
|
||||||
|
(t/deftest cors-middleware-allowlisted-origin
|
||||||
|
(let [handler (#'app.http.middleware/wrap-cors
|
||||||
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
||||||
|
#{"https://trusted.example"})
|
||||||
|
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://trusted.example"}))
|
||||||
|
headers (::yres/headers resp)]
|
||||||
|
|
||||||
|
(t/is (= 200 (::yres/status resp)))
|
||||||
|
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
|
||||||
|
(t/is (= "true" (get headers "access-control-allow-credentials")))
|
||||||
|
(t/is (= "Origin" (get headers "vary")))
|
||||||
|
(t/is (= "content-type" (get headers "access-control-expose-headers")))
|
||||||
|
(t/is (not (str/includes?
|
||||||
|
(get headers "access-control-allow-headers" "")
|
||||||
|
"cookie")))))
|
||||||
|
|
||||||
|
(t/deftest cors-middleware-non-allowlisted-origin
|
||||||
|
(let [handler (#'app.http.middleware/wrap-cors
|
||||||
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
||||||
|
#{"https://trusted.example"})
|
||||||
|
resp (handler (->MethodAwareDummyRequest :get {"origin" "https://attacker.example"}))
|
||||||
|
headers (::yres/headers resp)]
|
||||||
|
|
||||||
|
(t/is (= 200 (::yres/status resp)))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-origin")))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-credentials")))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-headers")))
|
||||||
|
(t/is (nil? (get headers "access-control-expose-headers")))
|
||||||
|
(t/is (= "Origin" (get headers "vary")))))
|
||||||
|
|
||||||
|
(t/deftest cors-middleware-preflight-allowlisted
|
||||||
|
(let [handler (#'app.http.middleware/wrap-cors
|
||||||
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
||||||
|
#{"https://trusted.example"})
|
||||||
|
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://trusted.example"}))
|
||||||
|
headers (::yres/headers resp)]
|
||||||
|
|
||||||
|
(t/is (= 204 (::yres/status resp)))
|
||||||
|
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
|
||||||
|
(t/is (= "true" (get headers "access-control-allow-credentials")))))
|
||||||
|
|
||||||
|
(t/deftest cors-middleware-preflight-non-allowlisted
|
||||||
|
(let [handler (#'app.http.middleware/wrap-cors
|
||||||
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
||||||
|
#{"https://trusted.example"})
|
||||||
|
resp (handler (->MethodAwareDummyRequest :options {"origin" "https://attacker.example"}))
|
||||||
|
headers (::yres/headers resp)]
|
||||||
|
|
||||||
|
(t/is (= 204 (::yres/status resp)))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-origin")))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-credentials")))))
|
||||||
|
|
||||||
|
(t/deftest cors-middleware-missing-origin
|
||||||
|
(let [handler (#'app.http.middleware/wrap-cors
|
||||||
|
(fn [_] {::yres/status 200 ::yres/headers {}})
|
||||||
|
#{"https://trusted.example"})
|
||||||
|
resp (handler (->MethodAwareDummyRequest :get {}))
|
||||||
|
headers (::yres/headers resp)]
|
||||||
|
|
||||||
|
(t/is (= 200 (::yres/status resp)))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-origin")))
|
||||||
|
(t/is (nil? (get headers "access-control-allow-credentials")))))
|
||||||
|
|
||||||
(t/deftest session-authz
|
(t/deftest session-authz
|
||||||
(let [cfg th/*system*
|
(let [cfg th/*system*
|
||||||
manager (session/inmemory-manager)
|
manager (session/inmemory-manager)
|
||||||
|
|||||||
36
backend/test/backend_tests/logical_deletion_test.clj
Normal file
36
backend/test/backend_tests/logical_deletion_test.clj
Normal 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"}}))))))
|
||||||
126
backend/test/backend_tests/media_test.clj
Normal file
126
backend/test/backend_tests/media_test.clj
Normal 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))))))
|
||||||
@ -154,7 +154,7 @@
|
|||||||
(t/is (nil? (sto/get-object storage (:media-id row1))))
|
(t/is (nil? (sto/get-object storage (:media-id row1))))
|
||||||
(t/is (some? (sto/get-object storage (:media-id row2))))
|
(t/is (some? (sto/get-object storage (:media-id row2))))
|
||||||
|
|
||||||
;; check that storage object is still exists but is marked as deleted
|
;; check that storage object is still exists but is marked as deleted.
|
||||||
(let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})]
|
(let [row (th/db-get :storage-object {:id (:media-id row1)} {::db/remove-deleted false})]
|
||||||
(t/is (nil? row))))))
|
(t/is (nil? row))))))
|
||||||
|
|
||||||
@ -254,6 +254,32 @@
|
|||||||
|
|
||||||
(t/is (some? (sto/get-object storage (:media-id row2)))))))
|
(t/is (some? (sto/get-object storage (:media-id row2)))))))
|
||||||
|
|
||||||
|
(t/deftest create-file-thumbnail-requires-edit-permissions
|
||||||
|
(let [owner (th/create-profile* 1)
|
||||||
|
viewer (th/create-profile* 2)
|
||||||
|
file (th/create-file* 1 {:profile-id (:id owner)
|
||||||
|
:project-id (:default-project-id owner)
|
||||||
|
:is-shared false
|
||||||
|
:revn 1})
|
||||||
|
_ (th/create-file-role* {:file-id (:id file)
|
||||||
|
:profile-id (:id viewer)
|
||||||
|
:role :viewer})
|
||||||
|
data {::th/type :create-file-thumbnail
|
||||||
|
::rpc/profile-id (:id viewer)
|
||||||
|
:file-id (:id file)
|
||||||
|
:revn 1
|
||||||
|
:media {:filename "sample.jpg"
|
||||||
|
:size 7923
|
||||||
|
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||||
|
:mtype "image/jpeg"}}
|
||||||
|
out (th/command! data)
|
||||||
|
error (:error out)]
|
||||||
|
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
(t/is (th/ex-info? error))
|
||||||
|
(t/is (th/ex-of-type? error :not-found))
|
||||||
|
(t/is (= 0 (count (th/db-query :file-thumbnail {:file-id (:id file)}))))))
|
||||||
|
|
||||||
(t/deftest error-on-direct-storage-obj-deletion
|
(t/deftest error-on-direct-storage-obj-deletion
|
||||||
(let [storage (::sto/storage th/*system*)
|
(let [storage (::sto/storage th/*system*)
|
||||||
profile (th/create-profile* 1)
|
profile (th/create-profile* 1)
|
||||||
|
|||||||
@ -186,8 +186,10 @@
|
|||||||
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
||||||
org-summary {:id organization-id
|
org-summary {:id organization-id
|
||||||
:name organization-name
|
:name organization-name
|
||||||
:teams [{:id (:id team-with-files)}
|
:teams [{:id (:id team-with-files)
|
||||||
{:id (:id empty-team)}]}
|
:is-your-penpot true}
|
||||||
|
{:id (:id empty-team)
|
||||||
|
:is-your-penpot true}]}
|
||||||
calls (atom [])
|
calls (atom [])
|
||||||
submitted (atom [])
|
submitted (atom [])
|
||||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||||
@ -222,6 +224,7 @@
|
|||||||
(let [{:keys [topic message]} (first @calls)]
|
(let [{:keys [topic message]} (first @calls)]
|
||||||
(t/is (= uuid/zero topic))
|
(t/is (= uuid/zero topic))
|
||||||
(t/is (= :organization-deleted (:type message)))
|
(t/is (= :organization-deleted (:type message)))
|
||||||
|
(t/is (= organization-id (:organization-id message)))
|
||||||
(t/is (= organization-name (:organization-name message)))
|
(t/is (= organization-name (:organization-name message)))
|
||||||
(t/is (= #{(:id team-with-files) (:id empty-team)}
|
(t/is (= #{(:id team-with-files) (:id empty-team)}
|
||||||
(set (:teams message))))
|
(set (:teams message))))
|
||||||
@ -254,12 +257,16 @@
|
|||||||
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
|
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
|
||||||
owned-orgs [{:id org-1-id
|
owned-orgs [{:id org-1-id
|
||||||
:name org-1-name
|
:name org-1-name
|
||||||
:teams [{:id (:id org-1-team-files)}
|
:teams [{:id (:id org-1-team-files)
|
||||||
{:id (:id org-1-team-empty)}]}
|
:is-your-penpot true}
|
||||||
|
{:id (:id org-1-team-empty)
|
||||||
|
:is-your-penpot true}]}
|
||||||
{:id org-2-id
|
{:id org-2-id
|
||||||
:name org-2-name
|
:name org-2-name
|
||||||
:teams [{:id (:id org-2-team-files)}
|
:teams [{:id (:id org-2-team-files)
|
||||||
{:id (:id org-2-team-empty)}]}]
|
:is-your-penpot true}
|
||||||
|
{:id (:id org-2-team-empty)
|
||||||
|
:is-your-penpot true}]}]
|
||||||
calls (atom [])
|
calls (atom [])
|
||||||
submitted (atom [])
|
submitted (atom [])
|
||||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||||
@ -313,6 +320,8 @@
|
|||||||
m2 (org-msg org-2-name)]
|
m2 (org-msg org-2-name)]
|
||||||
(t/is (some? m1))
|
(t/is (some? m1))
|
||||||
(t/is (some? m2))
|
(t/is (some? m2))
|
||||||
|
(t/is (= org-1-id (:organization-id m1)))
|
||||||
|
(t/is (= org-2-id (:organization-id m2)))
|
||||||
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
|
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
|
||||||
(set (:teams m1))))
|
(set (:teams m1))))
|
||||||
(t/is (= #{(:id org-1-team-empty)}
|
(t/is (= #{(:id org-1-team-empty)}
|
||||||
@ -561,6 +570,263 @@
|
|||||||
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
|
(t/is (= (:id outside-team) (:team-id (first remaining-target))))
|
||||||
(t/is (= 1 (count remaining-other))))))
|
(t/is (= 1 (count remaining-other))))))
|
||||||
|
|
||||||
|
(t/deftest delete-all-org-invitations-removes-org-and-org-team-invitations
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
org-summary {:id org-id
|
||||||
|
:teams [{:id (:id team-1)}
|
||||||
|
{:id (:id team-2)}]}
|
||||||
|
params {::th/type :delete-all-org-invitations
|
||||||
|
:organization-id org-id}]
|
||||||
|
|
||||||
|
;; Should be deleted: org-level invitation.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "alice@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should be deleted: team-level invitation in team-1 (belongs to org).
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-1)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "bob@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "admin"
|
||||||
|
:valid-until (ct/in-future "48h")})
|
||||||
|
|
||||||
|
;; Should be deleted: team-level invitation in team-2 (belongs to org),
|
||||||
|
;; even if expired.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-2)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "carol@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-past "1h")})
|
||||||
|
|
||||||
|
;; Should remain: invitation to a team outside the org.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id outside-team)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "dan@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should remain: invitation to a different organization.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id (uuid/random)
|
||||||
|
:team-id nil
|
||||||
|
:email-to "erin@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(let [calls (atom [])
|
||||||
|
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||||
|
(swap! calls conj {:method method :params params})
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
nil))]
|
||||||
|
(management-command-with-nitrate! params))
|
||||||
|
present? (fn [email] (seq (th/db-query :team-invitation {:email-to email})))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
|
||||||
|
;; get-org-summary was called with the right organization-id.
|
||||||
|
(t/is (= 1 (count @calls)))
|
||||||
|
(t/is (= :get-org-summary (-> @calls first :method)))
|
||||||
|
(t/is (= {:organization-id org-id} (-> @calls first :params)))
|
||||||
|
|
||||||
|
;; Org-level + team-in-org invitations are deleted.
|
||||||
|
(t/is (not (present? "alice@example.com")))
|
||||||
|
(t/is (not (present? "bob@example.com")))
|
||||||
|
(t/is (not (present? "carol@example.com")))
|
||||||
|
|
||||||
|
;; Invitations outside the org survive.
|
||||||
|
(t/is (present? "dan@example.com"))
|
||||||
|
(t/is (present? "erin@example.com")))))
|
||||||
|
|
||||||
|
(t/deftest delete-all-org-invitations-handles-org-with-no-teams
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
org-id (uuid/random)
|
||||||
|
params {::th/type :delete-all-org-invitations
|
||||||
|
:organization-id org-id}]
|
||||||
|
|
||||||
|
;; Org-level invitation should still be deleted.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "alice@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(let [out (with-redefs [nitrate/call (fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary {:id org-id :teams []}
|
||||||
|
nil))]
|
||||||
|
(management-command-with-nitrate! params))
|
||||||
|
remaining (th/db-query :team-invitation {:org-id org-id})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
(t/is (empty? remaining)))))
|
||||||
|
|
||||||
|
(t/deftest exists-org-team-invitations-for-non-members-reports-invitations-to-delete
|
||||||
|
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
|
||||||
|
profile (th/create-profile* 4 {:is-active true})
|
||||||
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
base-params {::th/type :exists-org-team-invitations-for-non-members
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:organization-id org-id
|
||||||
|
:team-ids [(:id team-1) (:id team-2)]
|
||||||
|
:member-ids [(:id member1)]}
|
||||||
|
exist! (fn [] (-> (management-command-with-nitrate! base-params)
|
||||||
|
:result
|
||||||
|
:exists))]
|
||||||
|
|
||||||
|
(t/is (false? (exist!)))
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-1)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "member1@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
(t/is (false? (exist!)))
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "pending@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
(t/is (false? (exist!)))
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id outside-team)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "outsider@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
(t/is (false? (exist!)))
|
||||||
|
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-2)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "orphan@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
(t/is (true? (exist!)))))
|
||||||
|
|
||||||
|
(t/deftest delete-org-team-invitations-for-non-members-removes-non-member-invitations
|
||||||
|
(let [member1 (th/create-profile* 1 {:is-active true :email "member1@example.com"})
|
||||||
|
profile (th/create-profile* 4 {:is-active true})
|
||||||
|
team-1 (th/create-team* 1 {:profile-id (:id profile)})
|
||||||
|
team-2 (th/create-team* 2 {:profile-id (:id profile)})
|
||||||
|
outside-team (th/create-team* 3 {:profile-id (:id profile)})
|
||||||
|
org-id (uuid/random)
|
||||||
|
params {::th/type :delete-org-team-invitations-for-non-members
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:organization-id org-id
|
||||||
|
:team-ids [(:id team-1) (:id team-2)]
|
||||||
|
:member-ids [(:id member1)]}]
|
||||||
|
|
||||||
|
;; Should remain: member1 is an org member.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-1)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "member1@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Org-level invitation remains (out of team cleanup scope).
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:org-id org-id
|
||||||
|
:team-id nil
|
||||||
|
:email-to "pending@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should be deleted: team invitation for non-member
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-2)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "pending@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should be deleted: orphaned invitation
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-2)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "orphan@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
;; Should be deleted: expired invitation.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id team-1)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "expired@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-past "1h")})
|
||||||
|
|
||||||
|
;; Should remain: outside org scope.
|
||||||
|
(th/db-insert! :team-invitation
|
||||||
|
{:id (uuid/random)
|
||||||
|
:team-id (:id outside-team)
|
||||||
|
:org-id nil
|
||||||
|
:email-to "outsider@example.com"
|
||||||
|
:created-by (:id profile)
|
||||||
|
:role "editor"
|
||||||
|
:valid-until (ct/in-future "24h")})
|
||||||
|
|
||||||
|
(let [out (management-command-with-nitrate! params)]
|
||||||
|
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (nil? (:result out)))
|
||||||
|
|
||||||
|
;; Verify remaining invitations.
|
||||||
|
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "member1@example.com"}))))
|
||||||
|
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "pending@example.com"}))))
|
||||||
|
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "orphan@example.com"}))))
|
||||||
|
(t/is (= 0 (count (th/db-query :team-invitation {:email-to "expired@example.com"}))))
|
||||||
|
(t/is (= 1 (count (th/db-query :team-invitation {:email-to "outsider@example.com"})))))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; Tests: remove-from-org
|
;; Tests: remove-from-org
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -808,7 +1074,8 @@
|
|||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
(t/is (= {:teams-to-delete 0
|
(t/is (= {:teams-to-delete 0
|
||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 0}
|
:teams-to-exit 0
|
||||||
|
:teams-to-detach 0}
|
||||||
(:result out)))))
|
(:result out)))))
|
||||||
|
|
||||||
(t/deftest get-remove-from-org-summary-with-teams-to-delete
|
(t/deftest get-remove-from-org-summary-with-teams-to-delete
|
||||||
@ -834,7 +1101,8 @@
|
|||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
(t/is (= {:teams-to-delete 1
|
(t/is (= {:teams-to-delete 1
|
||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 0}
|
:teams-to-exit 0
|
||||||
|
:teams-to-detach 0}
|
||||||
(:result out)))))
|
(:result out)))))
|
||||||
|
|
||||||
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
|
(t/deftest get-remove-from-org-summary-with-teams-to-transfer
|
||||||
@ -864,7 +1132,8 @@
|
|||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
(t/is (= {:teams-to-delete 0
|
(t/is (= {:teams-to-delete 0
|
||||||
:teams-to-transfer 1
|
:teams-to-transfer 1
|
||||||
:teams-to-exit 0}
|
:teams-to-exit 0
|
||||||
|
:teams-to-detach 0}
|
||||||
(:result out)))))
|
(:result out)))))
|
||||||
|
|
||||||
(t/deftest get-remove-from-org-summary-with-teams-to-exit
|
(t/deftest get-remove-from-org-summary-with-teams-to-exit
|
||||||
@ -893,7 +1162,8 @@
|
|||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
(t/is (= {:teams-to-delete 0
|
(t/is (= {:teams-to-delete 0
|
||||||
:teams-to-transfer 0
|
:teams-to-transfer 0
|
||||||
:teams-to-exit 1}
|
:teams-to-exit 1
|
||||||
|
:teams-to-detach 0}
|
||||||
(:result out)))))
|
(:result out)))))
|
||||||
|
|
||||||
(t/deftest get-remove-from-org-summary-does-not-mutate
|
(t/deftest get-remove-from-org-summary-does-not-mutate
|
||||||
|
|||||||
@ -19,7 +19,8 @@
|
|||||||
[backend-tests.storage-test :refer [configure-storage-backend]]
|
[backend-tests.storage-test :refer [configure-storage-backend]]
|
||||||
[buddy.core.bytes :as b]
|
[buddy.core.bytes :as b]
|
||||||
[clojure.test :as t]
|
[clojure.test :as t]
|
||||||
[datoteka.fs :as fs]))
|
[datoteka.fs :as fs]
|
||||||
|
[datoteka.io :as io]))
|
||||||
|
|
||||||
(t/use-fixtures :once th/state-init)
|
(t/use-fixtures :once th/state-init)
|
||||||
(t/use-fixtures :each th/database-reset)
|
(t/use-fixtures :each th/database-reset)
|
||||||
@ -39,6 +40,23 @@
|
|||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(:result out)))
|
(:result out)))
|
||||||
|
|
||||||
|
(t/deftest upload-tempfile-returns-fresh-object-for-same-content
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
|
||||||
|
_ (io/write* path "content")
|
||||||
|
params {::th/type :upload-tempfile
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
:content {:filename "export.png"
|
||||||
|
:path path
|
||||||
|
:mtype "image/png"
|
||||||
|
:size 7}}
|
||||||
|
out1 (th/management-command! params)
|
||||||
|
out2 (th/management-command! params)]
|
||||||
|
(t/is (nil? (:error out1)))
|
||||||
|
(t/is (nil? (:error out2)))
|
||||||
|
(t/is (not= (get-in out1 [:result :id])
|
||||||
|
(get-in out2 [:result :id])))))
|
||||||
|
|
||||||
(t/deftest duplicate-file
|
(t/deftest duplicate-file
|
||||||
(let [storage (-> (:app.storage/storage th/*system*)
|
(let [storage (-> (:app.storage/storage th/*system*)
|
||||||
(configure-storage-backend))
|
(configure-storage-backend))
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns backend-tests.rpc-nitrate-test
|
(ns backend-tests.rpc-nitrate-test
|
||||||
(:require
|
(:require
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as-alias db]
|
[app.db :as-alias db]
|
||||||
[app.nitrate :as nitrate]
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
@ -44,6 +45,13 @@
|
|||||||
:organization-id (:id org-summary)}
|
:organization-id (:id org-summary)}
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
|
(defn- nitrate-org-summary-only-mock
|
||||||
|
[org-summary]
|
||||||
|
(fn [_cfg method _params]
|
||||||
|
(case method
|
||||||
|
:get-org-summary org-summary
|
||||||
|
nil)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; Tests
|
;; Tests
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@ -279,6 +287,64 @@
|
|||||||
(let [team (th/db-get :team {:id (:id team1)})]
|
(let [team (th/db-get :team {:id (:id team1)})]
|
||||||
(t/is (nil? (:deleted-at team))))))))
|
(t/is (nil? (:deleted-at team))))))))
|
||||||
|
|
||||||
|
(t/deftest get-leave-org-summary-counts-default-team-as-delete-when-empty
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-default-team (th/create-team* 97 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)]
|
||||||
|
(let [out (th/command! {::th/type :get-leave-org-summary
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:default-team-id your-penpot-id})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= {:teams-to-delete 0
|
||||||
|
:teams-to-transfer 0
|
||||||
|
:teams-to-exit 0
|
||||||
|
:teams-to-detach 0}
|
||||||
|
(:result out)))))))
|
||||||
|
|
||||||
|
(t/deftest get-leave-org-summary-counts-default-team-as-keep-when-has-files
|
||||||
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
org-default-team (th/create-team* 96 {:profile-id (:id profile-user)})
|
||||||
|
project (th/create-project* 96 {:profile-id (:id profile-user)
|
||||||
|
:team-id (:id org-default-team)})
|
||||||
|
_ (th/create-file* 96 {:profile-id (:id profile-user)
|
||||||
|
:project-id (:id project)})
|
||||||
|
extra-team (th/create-team* 95 {:profile-id (:id profile-user)})
|
||||||
|
|
||||||
|
organization-id (uuid/random)
|
||||||
|
your-penpot-id (:id org-default-team)
|
||||||
|
org-summary (make-org-summary
|
||||||
|
:organization-id organization-id
|
||||||
|
:organization-name "Test Org"
|
||||||
|
:owner-id (:id profile-owner)
|
||||||
|
:your-penpot-teams [your-penpot-id]
|
||||||
|
:org-teams [(:id extra-team)])]
|
||||||
|
|
||||||
|
(with-redefs [nitrate/call (nitrate-org-summary-only-mock org-summary)]
|
||||||
|
(let [out (th/command! {::th/type :get-leave-org-summary
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:id organization-id
|
||||||
|
:default-team-id your-penpot-id})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
;; extra-team is deletable, default team has files and is preserved.
|
||||||
|
(t/is (= {:teams-to-delete 1
|
||||||
|
:teams-to-transfer 0
|
||||||
|
:teams-to-exit 0
|
||||||
|
:teams-to-detach 1}
|
||||||
|
(:result out)))))))
|
||||||
|
|
||||||
(t/deftest leave-org-error-org-owner-cannot-leave
|
(t/deftest leave-org-error-org-owner-cannot-leave
|
||||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)})
|
||||||
@ -650,6 +716,71 @@
|
|||||||
(t/is (= :validation (th/ex-type (:error out))))
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
(t/is (= :not-valid-teams (th/ex-code (:error out))))))))
|
||||||
|
|
||||||
|
(t/deftest all-team-members-in-orgs-returns-org-id->boolean-map
|
||||||
|
(let [profile-user (th/create-profile* 201 {:is-active true})
|
||||||
|
profile-other (th/create-profile* 202 {:is-active true})
|
||||||
|
team (th/create-team* 201 {:profile-id (:id profile-user)})
|
||||||
|
_ (th/create-team-role* {:team-id (:id team)
|
||||||
|
:profile-id (:id profile-other)
|
||||||
|
:role :editor})
|
||||||
|
team-member-ids (->> (th/db-query :team-profile-rel {:team-id (:id team)})
|
||||||
|
(map :profile-id)
|
||||||
|
(into #{}))
|
||||||
|
org-id-1 (uuid/random)
|
||||||
|
org-id-2 (uuid/random)
|
||||||
|
calls (atom [])]
|
||||||
|
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||||
|
nitrate/call (fn [_cfg method params]
|
||||||
|
(swap! calls conj [method params])
|
||||||
|
(case method
|
||||||
|
:get-org-membership {:is-member true
|
||||||
|
:organization-id (:organization-id params)}
|
||||||
|
:get-org-members (get {org-id-1 (vec team-member-ids)
|
||||||
|
org-id-2 [(:id profile-user)]}
|
||||||
|
(:organization-id params)
|
||||||
|
[])
|
||||||
|
nil))]
|
||||||
|
(let [out (th/command! {::th/type :all-team-members-in-orgs
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:team-id (:id team)
|
||||||
|
:organization-ids [org-id-1 org-id-2]})
|
||||||
|
methods (map first @calls)
|
||||||
|
membership-calls (count (filter #(= :get-org-membership %) methods))
|
||||||
|
get-members-calls (count (filter #(= :get-org-members %) methods))]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= {org-id-1 true
|
||||||
|
org-id-2 false}
|
||||||
|
(:result out)))
|
||||||
|
(t/is (= 2 membership-calls))
|
||||||
|
(t/is (= 2 get-members-calls))))))
|
||||||
|
|
||||||
|
(t/deftest all-team-members-in-orgs-fails-before-fetching-org-members
|
||||||
|
(let [profile-user (th/create-profile* 203 {:is-active true})
|
||||||
|
team (th/create-team* 203 {:profile-id (:id profile-user)})
|
||||||
|
org-id-1 (uuid/random)
|
||||||
|
org-id-2 (uuid/random)
|
||||||
|
calls (atom [])]
|
||||||
|
(with-redefs [cf/flags (conj cf/flags :nitrate)
|
||||||
|
nitrate/call (fn [_cfg method params]
|
||||||
|
(swap! calls conj [method params])
|
||||||
|
(case method
|
||||||
|
:get-org-membership (if (= (:organization-id params) org-id-2)
|
||||||
|
{:is-member false
|
||||||
|
:organization-id (:organization-id params)}
|
||||||
|
{:is-member true
|
||||||
|
:organization-id (:organization-id params)})
|
||||||
|
:get-org-members []
|
||||||
|
nil))]
|
||||||
|
(let [out (th/command! {::th/type :all-team-members-in-orgs
|
||||||
|
::rpc/profile-id (:id profile-user)
|
||||||
|
:team-id (:id team)
|
||||||
|
:organization-ids [org-id-1 org-id-2]})
|
||||||
|
methods (map first @calls)]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :validation (th/ex-type (:error out))))
|
||||||
|
(t/is (= :user-doesnt-belong-organization (th/ex-code (:error out))))
|
||||||
|
(t/is (= 0 (count (filter #(= :get-org-members %) methods))))))))
|
||||||
|
|
||||||
(t/deftest leave-org-error-reassign-on-non-owned-team
|
(t/deftest leave-org-error-reassign-on-non-owned-team
|
||||||
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
(let [profile-owner (th/create-profile* 1 {:is-active true})
|
||||||
profile-user (th/create-profile* 2 {:is-active true})
|
profile-user (th/create-profile* 2 {:is-active true})
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.email.blacklist :as email.blacklist]
|
[app.email.blacklist :as email.blacklist]
|
||||||
[app.email.whitelist :as email.whitelist]
|
[app.email.whitelist :as email.whitelist]
|
||||||
|
[app.nitrate :as nitrate]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.profile :as profile]
|
[app.rpc.commands.profile :as profile]
|
||||||
[app.tokens :as tokens]
|
[app.tokens :as tokens]
|
||||||
@ -90,17 +91,26 @@
|
|||||||
(t/is (not (contains? result :password))))))
|
(t/is (not (contains? result :password))))))
|
||||||
|
|
||||||
(t/testing "update profile"
|
(t/testing "update profile"
|
||||||
(let [data (assoc profile
|
(with-redefs [app.config/flags #{:nitrate}]
|
||||||
::th/type :update-profile
|
(with-redefs [nitrate/add-nitrate-licence-to-profile
|
||||||
::rpc/profile-id (:id profile)
|
(fn [_ profile]
|
||||||
:fullname "Full Name"
|
(assoc profile :subscription {:plan :pro}))]
|
||||||
:lang "en"
|
(let [data (assoc profile
|
||||||
:theme "dark")
|
::th/type :update-profile
|
||||||
out (th/command! data)]
|
::rpc/profile-id (:id profile)
|
||||||
|
:fullname "Full Name"
|
||||||
|
:lang "en"
|
||||||
|
:theme "dark")
|
||||||
|
out (th/command! data)]
|
||||||
|
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(t/is (map? (:result 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"
|
(t/testing "query profile after update"
|
||||||
(let [data {::th/type :get-profile
|
(let [data {::th/type :get-profile
|
||||||
@ -526,6 +536,65 @@
|
|||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(t/is (= 0 (:call-count @mock))))))))
|
(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
|
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
|
||||||
;; With email-verification ENABLED (the default), a brand-new
|
;; With email-verification ENABLED (the default), a brand-new
|
||||||
;; profile created via the invitation flow is NOT active yet, so
|
;; profile created via the invitation flow is NOT active yet, so
|
||||||
|
|||||||
107
backend/test/backend_tests/shell_test.clj
Normal file
107
backend/test/backend_tests/shell_test.clj
Normal 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))))))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user