Compare commits

..

1 Commits

Author SHA1 Message Date
Eva Marco
9e2bd5c38c 🐛 Fix hover position of lock proportion tooltip 2025-10-16 10:52:49 +02:00
514 changed files with 17821 additions and 26892 deletions

View File

@@ -84,10 +84,8 @@ jobs:
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
📦 *[PENPOT] Error building penpot bundles.*
❌ *[PENPOT] Error during the execution of the job*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
Bundle version: `${{ steps.vars.outputs.bundle_version }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -34,26 +34,18 @@ jobs:
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
- name: Download Penpot Bundles
id: bundles
env:
FILE_NAME: penpot-${{ steps.vars.outputs.gh_ref }}.zip
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
run: |
tmp=$(aws s3api head-object \
--bucket ${{ secrets.S3_BUCKET }} \
--key "$FILE_NAME" \
--query 'Metadata."bundle-version"' \
--output text)
echo "bundle_version=$tmp" >> $GITHUB_OUTPUT
pushd docker/images
aws s3 cp s3://${{ secrets.S3_BUCKET }}/$FILE_NAME .
unzip $FILE_NAME > /dev/null
mv penpot/backend bundle-backend
mv penpot/frontend bundle-frontend
mv penpot/exporter bundle-exporter
mv penpot/storybook bundle-storybook
popd
- name: Set up Docker Buildx
@@ -66,18 +58,6 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images:
frontend
backend
exporter
storybook
labels: |
bundle_version=${{ steps.bundles.outputs.bundle_version }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v6
env:
@@ -89,7 +69,6 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
@@ -104,7 +83,6 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
@@ -119,34 +97,5 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Build and push Storybook Docker image
uses: docker/build-push-action@v6
env:
DOCKER_IMAGE: 'storybook'
BUNDLE_PATH: './bundle-storybook'
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.storybook
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🐳 *[PENPOT] Error building penpot docker images.*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
📦 Bundle: `${{ steps.bundles.outputs.bundle_version }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check Commit Type
uses: gsactions/commit-message-checker@v2
with:
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
pattern: '^(Merge|Revert|:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s["A-Z].*[^.]$'
flags: 'gm'
error: 'Commit should match CONTRIBUTING.md guideline'
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request

View File

@@ -37,43 +37,36 @@ jobs:
ref: ${{ steps.vars.outputs.gh_ref }}
# --- Publicly release the docker images ---
- name: Configure ECR credentials
uses: aws-actions/configure-aws-credentials@v4
- name: Login to private registry
uses: docker/login-action@v3
with:
aws-access-key-id: ${{ secrets.DOCKER_USERNAME }}
aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }}
aws-region: ${{ secrets.AWS_REGION }}
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Skopeo
run: |
sudo apt-get update -y
sudo apt-get install -y skopeo
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Copy images from AWS ECR to Docker Hub
- name: Publish docker images to DockerHub
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
TAG: ${{ steps.vars.outputs.gh_ref }}
REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
HUB: ${{ secrets.PUB_DOCKER_HUB }}
run: |
aws ecr get-login-password --region $AWS_REGION | \
skopeo login --username AWS --password-stdin \
$DOCKER_REGISTRY
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
IMAGES=("frontend" "backend" "exporter" "storybook")
IMAGES=("frontend" "backend" "exporter")
EXTRA_TAGS=("main" "latest")
for image in "${IMAGES[@]}"; do
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/$PUB_DOCKER_USERNAME/$image:$TAG
docker pull "$REGISTRY/penpotapp/$image:$TAG"
docker tag "$REGISTRY/penpotapp/$image:$TAG" "penpotapp/$image:$TAG"
docker push "penpotapp/$image:$TAG"
for alias in main latest; do
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/$PUB_DOCKER_USERNAME/$image:$alias
for tag in "${EXTRA_TAGS[@]}"; do
docker tag "$REGISTRY/penpotapp/$image:$TAG" "penpotapp/$image:$tag"
docker push "penpotapp/$image:$tag"
done
done
@@ -100,15 +93,3 @@ jobs:
tag_name: ${{ steps.vars.outputs.gh_ref }}
name: ${{ steps.vars.outputs.gh_ref }}
body: ${{ steps.extract_release_notes.outputs.release_notes }}
- name: Notify Mattermost
if: failure()
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🚀 *[PENPOT] Error releasing penpot.*
📄 Triggered from ref: `${{ steps.vars.outputs.gh_ref }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra

View File

@@ -4,36 +4,18 @@
### :boom: Breaking changes & Deprecations
- The backend RPC API URLS are changed from `/api/rpc/command/<name>`
to `/api/main/methods/<name>` (the previou PATH is preserved for
backward compatibility; however, if you are a user of this API, it
is strongly recommended that you adapt your code to use the new
PATH.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
- Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
- Toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
### :bug: Bugs fixed
- Fix text line-height values are wrong [Taiga #12252](https://tree.taiga.io/project/penpot/issue/12252)
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix pan cursor not disabling viewport guides [Github #6985](https://github.com/penpot/penpot/issues/6985)
- Fix viewport resize on locked shapes [Taiga #11974](https://tree.taiga.io/project/penpot/issue/11974)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix on copy instance inside a components chain touched are missing [Taiga #12371](https://tree.taiga.io/project/penpot/issue/12371)
- Fix problem with multiple selection and shadows [Github #7437](https://github.com/penpot/penpot/issues/7437)
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
- Fix shortcut conflict in text editor (increase/decrease font size vs word selection)
- Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312)
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
## 2.11.0
## 2.11.0 (Unreleased)
### :boom: Breaking changes & Deprecations
@@ -61,6 +43,10 @@
services which use netty internally (redis connection, S3 SDK client). This
configuration is not very commonly used so don't expected real impact on any user.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
- New composite token: Typography [Taiga #10200](https://tree.taiga.io/project/penpot/us/10200)
@@ -70,7 +56,6 @@
- Alternative ways of creating variants - Button Viewport [Taiga #11931](https://tree.taiga.io/project/penpot/us/11931)
- Reorder properties for a component [Taiga #10225](https://tree.taiga.io/project/penpot/us/10225)
- File Data storage layout refactor [Github #7345](https://github.com/penpot/penpot/pull/7345)
- Make several queries optimization on comment threads [Github #7506](https://github.com/penpot/penpot/pull/7506)
### :bug: Bugs fixed
@@ -85,29 +70,7 @@
- Fix auto-width changes to fixed when switching variants [Taiga #12172](https://tree.taiga.io/project/penpot/issue/12172)
- Fix component number has no singular translation string [Taiga #12106](https://tree.taiga.io/project/penpot/issue/12106)
- Fix adding/removing identical text fills [Taiga #12287](https://tree.taiga.io/project/penpot/issue/12287)
- Fix scroll on the inspect tab [Taiga #12293](https://tree.taiga.io/project/penpot/issue/12293)
- Fix lock proportion tooltip [Taiga #12326](https://tree.taiga.io/project/penpot/issue/12326)
- Fix internal Error when selecting a set by name in the token theme editor [Taiga #12310](https://tree.taiga.io/project/penpot/issue/12310)
- Fix drag & drop functionality is swapping instead or reordering [Taiga #12254](https://tree.taiga.io/project/penpot/issue/12254)
- Fix variants not syncronizing tokens on switch [Taiga #12290](https://tree.taiga.io/project/penpot/issue/12290)
- Fix incorrect behavior of Alt + Drag for variants [Taiga #12309](https://tree.taiga.io/project/penpot/issue/12309)
- Fix text override is lost after switch [Taiga #12269](https://tree.taiga.io/project/penpot/issue/12269)
- Fix exporting a board crashing the app [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12384)
- Fix nested variant in a component doesn't keep inherited overrides [Taiga #12299](https://tree.taiga.io/project/penpot/issue/12299)
- Fix selected colors not showing colors from children shapes in multiple selection [Taiga #12384](https://tree.taiga.io/project/penpot/issue/12385)
- Fix scrollbar issue in design tab [Taiga #12367](https://tree.taiga.io/project/penpot/issue/12367)
- Fix library update notificacions showing when they should not [Taiga #12397](https://tree.taiga.io/project/penpot/issue/12397)
- Fix remove flex button doesnt work within variant [Taiga #12314](https://tree.taiga.io/project/penpot/issue/12314)
- Fix an error translation [Taiga #12402](https://tree.taiga.io/project/penpot/issue/12402)
- Fix problem with certain text input in some editable labels (pages, components, tokens...) being in conflict with the drag/drop functionality [Taiga #12316](https://tree.taiga.io/project/penpot/issue/12316)
- Fix not controlled theme renaming [Taiga #12411](https://tree.taiga.io/project/penpot/issue/12411)
- Fix paste without selection sends the new element in the back [Taiga #12382](https://tree.taiga.io/project/penpot/issue/12382)
- Fix options button does not work for comments created in the lower part of the screen [Taiga #12422](https://tree.taiga.io/project/penpot/issue/12422)
- Fix problem when checking usage with removed teams [Taiga #12442](https://tree.taiga.io/project/penpot/issue/12442)
- Fix focus mode persisting across page/file navigation [Taiga #12469](https://tree.taiga.io/project/penpot/issue/12469)
- Fix shadow color validation [Github #7705](https://github.com/penpot/penpot/pull/7705)
- Fix exception on selection blend-mode using keyboard [Github #7710](https://github.com/penpot/penpot/pull/7710)
- Fix crash when using decimal (floating-point) values for X/Y or width/height [Taiga #12543](https://tree.taiga.io/project/penpot/issue/12543)
## 2.10.1
@@ -115,10 +78,12 @@
- Improve workpace file loading [Github 7366](https://github.com/penpot/penpot/pull/7366)
### :bug: Bugs fixed
- Fix regression with text shapes creation with Plugins API [Taiga #12244](https://tree.taiga.io/project/penpot/issue/12244)
## 2.10.0
### :rocket: Epics and highlights
@@ -134,7 +99,7 @@
- Add efficiency enhancements to right sidebar [Github #7182](https://github.com/penpot/penpot/pull/7182)
- Add defaults for artboard drawing [Taiga #494](https://tree.taiga.io/project/penpot/us/494?milestone=465047)
- Continuous display of distances between elements when moving a layer with the keyboard [Taiga #1780](https://tree.taiga.io/project/penpot/us/1780)
- New Number token - unitless values [Taiga #10936](https://tree.taiga.io/project/penpot/us/10936)
- New Number token - unitless values [Taiga #10936](https://tree.taiga.io/project/penpot/us/10936)
- New font-family token [Taiga #10937](https://tree.taiga.io/project/penpot/us/10937)
- New text case token [Taiga #10942](https://tree.taiga.io/project/penpot/us/10942)
- New text-decoration token [Taiga #10941](https://tree.taiga.io/project/penpot/us/10941)
@@ -215,6 +180,7 @@
- Add info to apply-token event [Taiga #11710](https://tree.taiga.io/project/penpot/task/11710)
- Fix double click on set name input [Taiga #11747](https://tree.taiga.io/project/penpot/issue/11747)
### :bug: Bugs fixed
- Copying font size does not copy the unit [Taiga #11143](https://tree.taiga.io/project/penpot/issue/11143)

View File

@@ -28,8 +28,8 @@
com.google.guava/guava {:mvn/version "33.4.8-jre"}
funcool/yetti
{:git/tag "v11.8"
:git/sha "1d1b33f"
{:git/tag "v11.6"
:git/sha "94dc017"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}

View File

@@ -27,7 +27,6 @@
[app.common.transit :as t]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.main :as main]

View File

@@ -8,41 +8,38 @@
<body>
<p>
<strong>Feedback from:</strong><br />
<span>
<span>Name: </span>
<span><code>{{profile.fullname|abbreviate:25}}</code></span>
</span>
<br />
<span>
<span>Email: </span>
<span>{{profile.email}}</span>
</span>
<br />
<span>
<span>ID: </span>
<span><code>{{profile.id}}</code></span>
</span>
{% if profile %}
<span>
<span>Name: </span>
<span><code>{{profile.fullname|abbreviate:25}}</code></span>
</span>
<br />
<span>
<span>Email: </span>
<span>{{profile.email}}</span>
</span>
<br />
<span>
<span>ID: </span>
<span><code>{{profile.id}}</code></span>
</span>
{% else %}
<span>
<span>Email: </span>
<span>{{profile.email}}</span>
</span>
{% endif %}
</p>
<p>
<strong>Subject:</strong><br />
<span>{{feedback-subject|abbreviate:300}}</span>
<span>{{subject|abbreviate:300}}</span>
</p>
<p>
<strong>Type:</strong><br />
<span>{{feedback-type|abbreviate:300}}</span>
</p>
{% if feedback-error-href %}
<p>
<strong>Error HREF:</strong><br />
<span>{{feedback-error-href|abbreviate:500}}</span>
</p>
{% endif %}
<p>
<strong>Message:</strong><br />
{{feedback-content|linebreaks-br}}
{{content|linebreaks-br|safe}}
</p>
</body>
</html>

View File

@@ -1 +1 @@
[PENPOT FEEDBACK]: {{feedback-subject}}
[PENPOT FEEDBACK]: {{subject}}

View File

@@ -1,10 +1,9 @@
From: {{profile.fullname}} <{{profile.email}}> / {{profile.id}}
Subject: {{feedback-subject}}
Type: {{feedback-type}}
{%- if feedback-error-href %}
HREF: {{feedback-error-href}}
{% endif -%}
{% if profile %}
Feedback profile: {{profile.fullname}} <{{profile.email}}> / {{profile.id}}
{% else %}
Feedback from: {{email}}
{% endif %}
Message:
Subject: {{subject}}
{{feedback-content}}
{{content}}

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{{label|upper}} API Documentation</title>
<title>Builtin API Documentation - Penpot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -19,7 +19,7 @@
<body>
<main>
<header>
<h1>{{label|upper}}: API Documentation (v{{version}})</h1>
<h1>Penpot API Documentation (v{{version}})</h1>
<small class="menu">
[
<nav>
@@ -31,10 +31,9 @@
</header>
<section class="doc-content">
<h2>INTRODUCTION</h2>
<p>This documentation is intended to be a general overview of
the {{label}} API. If you prefer, you can
use <a href="{{openapi}}">Swagger/OpenAPI</a> as
alternative.</p>
<p>This documentation is intended to be a general overview of the penpot RPC API.
If you prefer, you can use <a href="/api/openapi.json">OpenAPI</a>
and/or <a href="/api/openapi">SwaggerUI</a> as alternative.</p>
<h2>GENERAL NOTES</h2>
@@ -44,7 +43,7 @@
that starts with <b>get-</b> in the name, can use GET HTTP
method which in many cases benefits from the HTTP cache.</p>
{% block auth-section %}
<h3>Authentication</h3>
<p>The penpot backend right now offers two way for authenticate the request:
<b>cookies</b> (the same mechanism that we use ourselves on accessing the API from the
@@ -57,10 +56,9 @@
<p>The access token can be obtained on the appropriate section on profile settings
and it should be provided using <b>`Authorization`</b> header with <b>`Token
&lt;token-string&gt;`</b> value.</p>
{% endblock %}
<h3>Content Negotiation</h3>
<p>This API operates indistinctly with: <b>`application/json`</b>
<p>The penpot API by default operates indistinctly with: <b>`application/json`</b>
and <b>`application/transit+json`</b> content types. You should specify the
desired content-type on the <b>`Accept`</b> header, the transit encoding is used
by default.</p>
@@ -77,16 +75,13 @@
standard <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch
API</a></p>
{% block limits-section %}
<h3>Limits</h3>
<p>The rate limit work per user basis (this means that different api keys share
the same rate limit). For now the limits are not documented because we are
studying and analyzing the data. As a general rule, it should not be abused, if an
abusive use is detected, we will proceed to block the user's access to the
API.</p>
{% endblock %}
{% block webhooks-section %}
<h3>Webhooks</h3>
<p>All methods that emit webhook events are marked with flag <b>WEBHOOK</b>, the
data structure defined on each method represents the <i>payload</i> of the
@@ -102,11 +97,9 @@
"profileId": "db601c95-045f-808b-8002-361312e63531"
}
</pre>
{% endblock %}
</section>
<section class="rpc-doc-content">
<h2>METHODS REFERENCE:</h2>
<h2>RPC METHODS REFERENCE:</h2>
<ul class="rpc-items">
{% for item in methods %}
{% include "app/templates/api-doc-entry.tmpl" with item=item %}

View File

@@ -1 +0,0 @@
{% extends "app/templates/api-doc.tmpl" %}

View File

@@ -1,10 +0,0 @@
{% extends "app/templates/api-doc.tmpl" %}
{% block auth-section %}
{% endblock %}
{% block limits-section %}
{% endblock %}
{% block webhooks-section %}
{% endblock %}

View File

@@ -7,7 +7,7 @@
name="description"
content="SwaggerUI"
/>
<title>{{label|upper}} API</title>
<title>PENPOT Swagger UI</title>
<style>{{swagger-css|safe}}</style>
</head>
<body>
@@ -16,7 +16,7 @@
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '{{uri}}',
url: '{{public-uri}}/api/openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,

View File

@@ -20,7 +20,6 @@ export PENPOT_FLAGS="\
enable-audit-log \
enable-transit-readable-response \
enable-demo-users \
enable-user-feedback \
disable-secure-session-cookies \
enable-smtp \
enable-prepl-server \
@@ -47,8 +46,6 @@ export PENPOT_MEDIA_MAX_FILE_SIZE=104857600
# Setup default multipart upload size to 300MiB
export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
export AWS_ACCESS_KEY_ID=penpot-devenv
export AWS_SECRET_ACCESS_KEY=penpot-devenv
export PENPOT_OBJECTS_STORAGE_BACKEND=s3

View File

@@ -21,8 +21,6 @@
[app.email.whitelist :as email.whitelist]
[app.http.client :as http]
[app.http.errors :as errors]
[app.http.middleware :as mw]
[app.http.security :as sec]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.rpc :as rpc]
@@ -692,9 +690,8 @@
(defmethod ig/init-key ::routes
[_ cfg]
(let [cfg (update cfg :providers d/without-nils)]
["/api" {:middleware [[mw/cors]
[sec/client-header-check]
[provider-lookup cfg]]}
["" {:middleware [[session/authz cfg]
[provider-lookup cfg]]}
["/auth/oauth"
["/:provider"
{:handler auth-handler

View File

@@ -550,7 +550,7 @@
[cfg data file-id]
(let [library-ids (get-libraries cfg [file-id])]
(reduce (fn [data library-id]
(if-let [library (get-file cfg library-id :include-deleted? true)]
(if-let [library (get-file cfg library-id)]
(ctf/absorb-assets data (:data library))
data))
data
@@ -749,7 +749,7 @@
l.version
FROM libs AS l
INNER JOIN project AS p ON (p.id = l.project_id)
WHERE l.deleted_at IS NULL;")
WHERE l.deleted_at IS NULL OR l.deleted_at > now();")
(defn get-file-libraries
[conn file-id]

View File

@@ -228,7 +228,6 @@
(db/tx-run! cfg (fn [cfg]
(cond-> (bfc/get-file cfg file-id
{:realize? true
:include-deleted? true
:lock-for-update? true})
detach?
(-> (ctf/detach-external-references file-id)
@@ -286,12 +285,14 @@
(let [file (cond-> (select-keys file bfc/file-attrs)
(:options data)
(assoc :options (:options data)))
(assoc :options (:options data))
file (-> file
(dissoc :data)
(dissoc :deleted-at)
(encode-file))
:always
(dissoc :data))
file (cond-> file
:always
(encode-file))
path (str "files/" file-id ".json")]
(write-entry! output path file))

View File

@@ -47,7 +47,6 @@
:auto-file-snapshot-timeout "3h"
:public-uri "http://localhost:3449"
:host "localhost"
:tenant "default"
@@ -58,8 +57,6 @@
:objects-storage-backend "fs"
:objects-storage-fs-directory "assets"
:auth-token-cookie-name "auth-token"
:assets-path "/internal/assets/"
:smtp-default-reply-to "Penpot <no-reply@example.com>"
:smtp-default-from "Penpot <no-reply@example.com>"
@@ -93,7 +90,7 @@
[:secret-key {:optional true} :string]
[:tenant {:optional false} :string]
[:public-uri {:optional false} ::sm/uri]
[:public-uri {:optional false} :string]
[:host {:optional false} :string]
[:http-server-port {:optional true} ::sm/int]
@@ -322,9 +319,5 @@
([key default]
(c/get config key default)))
(defn logging-context
[]
{:version/backend (:full version)})
;; Set value for all new threads bindings.
(alter-var-root #'*assert* (constantly (contains? flags :backend-asserts)))

View File

@@ -704,12 +704,6 @@
(and (sql-exception? cause)
(= "40001" (.getSQLState ^java.sql.SQLException cause))))
(defn duplicate-key-error?
[cause]
(and (sql-exception? cause)
(= "23505" (.getSQLState ^java.sql.SQLException cause))))
(extend-protocol jdbc.prepare/SettableParameter
clojure.lang.Keyword
(set-parameter [^clojure.lang.Keyword v ^PreparedStatement s ^long i]

View File

@@ -7,7 +7,6 @@
(ns app.email
"Main api for send emails."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
@@ -94,44 +93,36 @@
headers)))
(defn- assign-body
[^MimeMessage mmsg {:keys [body charset attachments] :or {charset "utf-8"}}]
(let [mixed-mpart (MimeMultipart. "mixed")]
[^MimeMessage mmsg {:keys [body charset] :or {charset "utf-8"}}]
(let [mpart (MimeMultipart. "mixed")]
(cond
(string? body)
(let [text-part (MimeBodyPart.)]
(.setText text-part ^String body ^String charset)
(.addBodyPart mixed-mpart text-part))
(let [bpart (MimeBodyPart.)]
(.setContent bpart ^String body (str "text/plain; charset=" charset))
(.addBodyPart mpart bpart))
(vector? body)
(let [mmp (MimeMultipart. "alternative")
mbp (MimeBodyPart.)]
(.addBodyPart mpart mbp)
(.setContent mbp mmp)
(doseq [item body]
(let [mbp (MimeBodyPart.)]
(.setContent mbp
^String (:content item)
^String (str (:type item "text/plain") "; charset=" charset))
(.addBodyPart mmp mbp))))
(map? body)
(let [content-part (MimeBodyPart.)
alternative-mpart (MimeMultipart. "alternative")]
(when-let [content (get body "text/html")]
(let [html-part (MimeBodyPart.)]
(.setContent html-part ^String content
(str "text/html; charset=" charset))
(.addBodyPart alternative-mpart html-part)))
(when-let [content (get body "text/plain")]
(let [text-part (MimeBodyPart.)]
(.setText text-part ^String content ^String charset)
(.addBodyPart alternative-mpart text-part)))
(.setContent content-part alternative-mpart)
(.addBodyPart mixed-mpart content-part))
(let [bpart (MimeBodyPart.)]
(.setContent bpart
^String (:content body)
^String (str (:type body "text/plain") "; charset=" charset))
(.addBodyPart mpart bpart))
:else
(throw (IllegalArgumentException. "invalid email body provided")))
(doseq [[name content] attachments]
(prn "attachment" name)
(let [attachment-part (MimeBodyPart.)]
(.setFileName attachment-part ^String name)
(.setContent attachment-part ^String content (str "text/plain; charset=" charset))
(.addBodyPart mixed-mpart attachment-part)))
(.setContent mmsg mixed-mpart)
(throw (ex-info "Unsupported type" {:body body})))
(.setContent mmsg mpart)
mmsg))
(defn- opts->props
@@ -219,26 +210,24 @@
(ex/raise :type :internal
:code :missing-email-templates))
{:subject subj
:body (d/without-nils
{"text/plain" text
"text/html" html})}))
:body (into
[{:type "text/plain"
:content text}]
(when html
[{:type "text/html"
:content html}]))}))
(def ^:private schema:params
[:map {:title "Email Params"}
(def ^:private schema:context
[:map
[:to [:or ::sm/email [::sm/vec ::sm/email]]]
[:reply-to {:optional true} ::sm/email]
[:from {:optional true} ::sm/email]
[:lang {:optional true} ::sm/text]
[:subject {:optional true} ::sm/text]
[:priority {:optional true} [:enum :high :low]]
[:extra-data {:optional true} ::sm/text]
[:body {:optional true}
[:or :string [:map-of :string :string]]]
[:attachments {:optional true}
[:map-of :string :string]]])
[:extra-data {:optional true} ::sm/text]])
(def ^:private check-params
(sm/check-fn schema:params))
(def ^:private check-context
(sm/check-fn schema:context))
(defn template-factory
[& {:keys [id schema]}]
@@ -246,9 +235,9 @@
(let [check-fn (if schema
(sm/check-fn schema)
(constantly nil))]
(fn [params]
(let [params (-> params check-params check-fn)
email (build-email-template id params)]
(fn [context]
(let [context (-> context check-context check-fn)
email (build-email-template id context)]
(when-not email
(ex/raise :type :internal
:code :email-template-does-not-exists
@@ -256,40 +245,35 @@
:template-id id))
(cond-> (assoc email :id (name id))
(:extra-data params)
(assoc :extra-data (:extra-data params))
(:extra-data context)
(assoc :extra-data (:extra-data context))
(seq (:attachments params))
(assoc :attachments (:attachments params))
(:from context)
(assoc :from (:from context))
(:from params)
(assoc :from (:from params))
(:reply-to context)
(assoc :reply-to (:reply-to context))
(:reply-to params)
(assoc :reply-to (:reply-to params))
(:to params)
(assoc :to (:to params)))))))
(:to context)
(assoc :to (:to context)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC HIGH-LEVEL API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn render
[email-factory params]
(email-factory params))
[email-factory context]
(email-factory context))
(defn send!
"Schedule an already defined email to be sent using asynchronously
using worker task."
[{:keys [::conn ::factory] :as params}]
[{:keys [::conn ::factory] :as context}]
(assert (db/connectable? conn) "expected a valid database connection or pool")
(let [email (if factory
(factory params)
(-> params
(dissoc params)
(check-params)))]
(factory context)
(dissoc context ::conn))]
(wrk/submit! {::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
@@ -359,10 +343,8 @@
(def ^:private schema:feedback
[:map
[:feedback-subject ::sm/text]
[:feedback-type ::sm/text]
[:feedback-content ::sm/text]
[:profile :map]])
[:subject ::sm/text]
[:content ::sm/text]])
(def user-feedback
"A profile feedback email."

View File

@@ -25,6 +25,7 @@
[app.main :as-alias main]
[app.metrics :as mtx]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[integrant.core :as ig]
[reitit.core :as r]
@@ -148,6 +149,7 @@
[:map
[::ws/routes schema:routes]
[::rpc/routes schema:routes]
[::rpc.doc/routes schema:routes]
[::oidc/routes schema:routes]
[::assets/routes schema:routes]
[::debug/routes schema:routes]
@@ -169,9 +171,8 @@
[sec/sec-fetch-metadata]
[mw/params]
[mw/format-response]
[mw/auth {:bearer (partial session/decode-token cfg)
:cookie (partial session/decode-token cfg)
:token (partial actoken/decode-token cfg)}]
[session/soft-auth cfg]
[actoken/soft-auth cfg]
[mw/parse-request]
[mw/errors errors/handle]
[mw/restrict-methods]]}
@@ -187,5 +188,9 @@
(::mgmt/routes cfg)]
(::ws/routes cfg)
(::oidc/routes cfg)
(::rpc/routes cfg)]]))
["/api" {:middleware [[mw/cors]
[sec/client-header-check]]}
(::oidc/routes cfg)
(::rpc.doc/routes cfg)
(::rpc/routes cfg)]]]))

View File

@@ -9,19 +9,23 @@
[app.common.logging :as l]
[app.config :as cf]
[app.db :as db]
[app.http.auth :as-alias http.auth]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.tokens :as tokens]))
[app.tokens :as tokens]
[yetti.request :as yreq]))
(defn decode-token
(def header-re #"(?i)^Token\s+(.*)")
(defn get-token
[request]
(some->> (yreq/get-header request "authorization")
(re-matches header-re)
(second)))
(defn- decode-token
[cfg token]
(try
(tokens/verify cfg {:token token :iss "access-token"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(when token
(tokens/verify cfg {:token token :iss "access-token"})))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at
@@ -31,27 +35,47 @@
OR (expires_at > now()));")
(defn- get-token-data
[pool claims]
[pool token-id]
(when-not (db/read-only? pool)
(when-let [token-id (-> (deref claims) (get :tid))]
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{})))))
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{}))))
(defn- wrap-soft-auth
"Soft Authentication, will be executed synchronously on the undertow
worker thread."
[handler cfg]
(letfn [(handle-request [request]
(try
(let [token (get-token request)
claims (decode-token cfg token)]
(cond-> request
(map? claims)
(assoc ::id (:tid claims))))
(catch Throwable cause
(l/trace :hint "exception on decoding malformed token" :cause cause)
request)))]
(fn [request]
(handler (handle-request request)))))
(defn- wrap-authz
"Authorization middleware, will be executed synchronously on vthread."
[handler {:keys [::db/pool]}]
(fn [{:keys [::http.auth/token-type] :as request}]
(if (= :token token-type)
(let [{:keys [perms profile-id expires-at]} (some->> (get request ::http.auth/claims)
(get-token-data pool))]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))
(fn [request]
(let [{:keys [perms profile-id expires-at]} (some->> (::id request) (get-token-data pool))]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))))
(handler request))))
(def soft-auth
{:name ::soft-auth
:compile (fn [& _]
(when (contains? cf/flags :access-tokens)
wrap-soft-auth))})
(def authz
{:name ::authz

View File

@@ -13,7 +13,6 @@
[app.config :as cf]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.auth :as-alias auth]
[app.http.session :as-alias session]
[app.util.inet :as inet]
[clojure.spec.alpha :as s]
@@ -23,15 +22,18 @@
(defn request->context
"Extracts error report relevant context data from request."
[request]
(let [claims (some-> (get request ::auth/claims) deref)]
(-> (cf/logging-context)
(assoc :request/path (:path request))
(assoc :request/method (:method request))
(assoc :request/params (:params request))
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(let [claims (-> {}
(into (::session/token-claims request))
(into (::actoken/token-claims request)))]
{:request/path (:path request)
:request/method (:method request)
:request/params (:params request)
:request/user-agent (yreq/get-header request "user-agent")
:request/ip-addr (inet/parse-request request)
:request/profile-id (:uid claims)
:version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")
:version/backend (:full cf/version)}))
(defmulti handle-error
(fn [cause _ _]

View File

@@ -13,7 +13,7 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.http.middleware :as mw]
[app.http.access-token :refer [get-token]]
[app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup]
@@ -32,6 +32,20 @@
[_ params]
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
(def ^:private auth
{:name ::auth
:compile
(fn [_ _]
(fn [handler shared-key]
(if shared-key
(fn [request]
(let [token (get-token request)]
(if (= token shared-key)
(handler request)
{::yres/status 403})))
(fn [_ _]
{::yres/status 403}))))})
(def ^:private default-system
{:name ::default-system
:compile
@@ -51,7 +65,7 @@
(defmethod ig/init-key ::routes
[_ cfg]
["" {:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
["" {:middleware [[auth (cf/get :management-api-shared-key)]
[default-system cfg]
[transaction]]}
["/authenticate"

View File

@@ -12,7 +12,6 @@
[app.common.schema :as-alias sm]
[app.common.transit :as t]
[app.config :as cf]
[app.http.auth :as-alias auth]
[app.http.errors :as errors]
[app.util.pointer-map :as pmap]
[cuerdas.core :as str]
@@ -241,61 +240,3 @@
(if (contains? allowed method)
(handler request)
{::yres/status 405}))))))})
(defn- wrap-auth
[handler decoders]
(let [token-re
#"(?i)^(Token|Bearer)\s+(.*)"
get-token-from-authorization
(fn [request]
(when-let [[_ token-type token] (some->> (yreq/get-header request "authorization")
(re-matches token-re))]
(if (= "token" (str/lower token-type))
[:token token]
[:bearer token])))
get-token-from-cookie
(fn [request]
(let [cname (cf/get :auth-token-cookie-name)
token (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? token)
[:cookie token])))
get-token
(some-fn get-token-from-cookie get-token-from-authorization)
process-request
(fn [request]
(if-let [[token-type token] (get-token request)]
(let [request (-> request
(assoc ::auth/token token)
(assoc ::auth/token-type token-type))
decoder (get decoders token-type)]
(if (fn? decoder)
(assoc request ::auth/claims (delay (decoder token)))
request))
request))]
(fn [request]
(-> request process-request handler))))
(def auth
{:name ::auth
:compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth
[handler shared-key]
(if shared-key
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler request)
{::yres/status 403})))
(fn [_ _]
{::yres/status 403})))
(def shared-key-auth
{:name ::shared-key-auth
:compile (constantly wrap-shared-key-auth)})

View File

@@ -14,7 +14,6 @@
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.http.auth :as-alias http.auth]
[app.http.session.tasks :as-alias tasks]
[app.main :as-alias main]
[app.setup :as-alias setup]
@@ -27,6 +26,13 @@
;; DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A default cookie name for storing the session.
(def default-auth-token-cookie-name "auth-token")
;; A cookie that we can use to check from other sites of the same
;; domain if a user is authenticated.
(def default-auth-data-cookie-name "auth-data")
;; Default value for cookie max-age
(def default-cookie-max-age (ct/duration {:days 7}))
@@ -163,7 +169,7 @@
[{:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request response]
(let [cname (cf/get :auth-token-cookie-name)
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (yreq/get-cookie request cname)]
(l/trc :hint "delete" :profile-id (:profile-id request))
(some->> (:value cookie) (delete! manager))
@@ -177,14 +183,21 @@
(tokens/generate cfg {:iss "authentication"
:iat created-at
:uid profile-id}))
(defn decode-token
(defn- decode-token
[cfg token]
(try
(tokens/verify cfg {:token token :iss "authentication"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(when token
(tokens/verify cfg {:token token :iss "authentication"})))
(defn- get-token
[request]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? cookie)
cookie)))
(defn- get-session
[manager token]
(some->> token (read manager)))
(defn- renew-session?
[{:keys [updated-at] :as session}]
@@ -192,38 +205,44 @@
(let [elapsed (ct/diff updated-at (ct/now))]
(neg? (compare default-renewal-max-age elapsed)))))
(defn- wrap-authz
(defn- wrap-soft-auth
[handler {:keys [::manager] :as cfg}]
(assert (manager? manager) "expected valid session manager")
(fn [{:keys [::http.auth/token-type] :as request}]
(cond
(= token-type :cookie)
(let [session (some->> (get request ::http.auth/token)
(read manager))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::id (:id session))))
(letfn [(handle-request [request]
(try
(let [token (get-token request)
claims (decode-token cfg token)]
(cond-> request
(map? claims)
(-> (assoc ::token-claims claims)
(assoc ::token token))))
(catch Throwable cause
(l/trc :hint "exception on decoding malformed token" :cause cause)
request)))]
response (handler request)]
(fn [request]
(handler (handle-request request)))))
(if (renew-session? session)
(let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)))
response))
(defn- wrap-authz
[handler {:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request]
(let [session (get-session manager (::token request))
request (cond-> request
(some? session)
(assoc ::profile-id (:profile-id session)
::id (:id session)))
response (handler request)]
(= token-type :bearer)
(let [session (some->> (get request ::http.auth/token)
(read manager))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::id (:id session))))]
(handler request))
(if (renew-session? session)
(let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)))
response))))
:else
(handler request))))
(def soft-auth
{:name ::soft-auth
:compile (constantly wrap-soft-auth)})
(def authz
{:name ::authz
@@ -240,7 +259,7 @@
secure? (contains? cf/flags :secure-session-cookies)
strict? (contains? cf/flags :strict-session-cookies)
cors? (contains? cf/flags :cors)
name (cf/get :auth-token-cookie-name)
name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
comment (str "Renewal at: " (ct/format-inst renewal :rfc1123))
cookie {:path "/"
:http-only true
@@ -253,7 +272,7 @@
(defn- clear-auth-token-cookie
[response]
(let [cname (cf/get :auth-token-cookie-name)]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(update response :cookies assoc cname {:path "/" :value "" :max-age 0})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -21,7 +21,7 @@
[app.http.client :as-alias http.client]
[app.http.debug :as-alias http.debug]
[app.http.management :as mgmt]
[app.http.session :as session]
[app.http.session :as-alias session]
[app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws]
[app.loggers.webhooks :as-alias webhooks]
@@ -31,6 +31,7 @@
[app.redis :as-alias rds]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[app.srepl :as-alias srepl]
[app.storage :as-alias sto]
@@ -279,6 +280,7 @@
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
::setup/props (ig/ref ::setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
@@ -319,7 +321,6 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/instance (ig/ref :app.nitrate/instance)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
@@ -336,29 +337,14 @@
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/instance
{::http.client/client (ig/ref ::http.client/client)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}
:app.rpc.doc/routes
{:app.rpc/methods (ig/ref :app.rpc/methods)}
::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
{::rpc/methods (ig/ref :app.rpc/methods)
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)

View File

@@ -13,14 +13,11 @@
[app.common.schema :as sm]
[app.common.spec :as us]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as actoken]
[app.http.client :as-alias http.client]
[app.http.middleware :as mw]
[app.http.security :as sec]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as-alias main]
@@ -29,7 +26,6 @@
[app.redis :as rds]
[app.rpc.climit :as climit]
[app.rpc.cond :as cond]
[app.rpc.doc :as doc]
[app.rpc.helpers :as rph]
[app.rpc.retry :as retry]
[app.rpc.rlimit :as rlimit]
@@ -40,6 +36,7 @@
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[promesa.core :as p]
[yetti.request :as yreq]
[yetti.response :as yres]))
@@ -47,7 +44,7 @@
(defn- default-handler
[_]
(ex/raise :type :not-found))
(p/rejected (ex/error :type :not-found)))
(defn- handle-response-transformation
[response request mdata]
@@ -67,13 +64,9 @@
(let [mdata (meta result)
response (if (fn? result)
(result request)
(let [result (rph/unwrap result)
status (::http/status mdata 200)
headers (cond-> (::http/headers mdata {})
(yres/stream-body? result)
(assoc "content-type" "application/octet-stream"))]
{::yres/status status
::yres/headers headers
(let [result (rph/unwrap result)]
{::yres/status (::http/status mdata 200)
::yres/headers (::http/headers mdata {})
::yres/body result}))]
(-> response
(handle-response-transformation request mdata)
@@ -95,46 +88,43 @@
(str/blank? origin))
origin)))
(defn- make-rpc-handler
(defn- rpc-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods]
(let [methods (update-vals methods peek)]
(fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
[methods {:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (vary-meta data assoc ::http/request request)
handler-fn (get methods (keyword handler-name) default-handler)]
data (vary-meta data assoc ::http/request request)
handler-fn (get methods (keyword handler-name) default-handler)]
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
;; FIXME: why we have this cond enabled here, we need to move it outside this handler
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(handle-response request response)))))))
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(handle-response request response)))))
(defn- wrap-metrics
"Wrap service method with metrics measurement."
@@ -211,7 +201,7 @@
::sm/explain (explain params)))))))
f))
(defn- wrap
(defn- wrap-all
[cfg f mdata]
(as-> f $
(wrap-db-transaction cfg $ mdata)
@@ -225,30 +215,17 @@
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(defn- wrap-management
(defn- wrap
[cfg f mdata]
(as-> f $
(wrap-db-transaction cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)
(wrap-metrics cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(l/trc :hint "register method" :name (::sv/name mdata))
(let [f (wrap-all cfg f mdata)]
(partial f cfg)))
(defn- process-method
[cfg module wrap-fn [f mdata]]
(l/trc :hint "add method" :module module :name (::sv/name mdata))
(let [f (wrap-fn cfg f mdata)
k (keyword (::sv/name mdata))]
[k [mdata (partial f cfg)]]))
[cfg [vfn mdata]]
[(keyword (::sv/name mdata)) [mdata (wrap cfg vfn mdata)]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API METHODS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resolve-methods
(defn- resolve-command-methods
[cfg]
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
(->> (sv/scan-ns
@@ -277,7 +254,7 @@
'app.rpc.commands.verify-token
'app.rpc.commands.viewer
'app.rpc.commands.webhooks)
(map (partial process-method cfg "rpc" wrap))
(map (partial process-method cfg))
(into {}))))
(def ^:private schema:methods-params
@@ -301,49 +278,7 @@
(defmethod ig/init-key ::methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
(resolve-methods cfg)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MANAGEMENT METHODS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resolve-management-methods
[cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription)
(map (partial process-method cfg "management" wrap-management))
(into {}))))
(def ^:private schema:management-methods-params
[:map {:title "management-methods-params"}
::session/manager
::http.client/client
::db/pool
::rds/pool
::mbus/msgbus
::sto/storage
::mtx/metrics
::setup/props])
(defmethod ig/assert-key ::management-methods
[_ params]
(assert (sm/check schema:management-methods-params params)))
(defmethod ig/init-key ::management-methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
(resolve-management-methods cfg)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ROUTES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- redirect
[href]
(fn [_]
{::yres/status 308
::yres/headers {"location" (str href)}}))
(resolve-command-methods cfg)))
(def ^:private schema:methods
[:map-of :keyword [:tuple :map ::sm/fn]])
@@ -358,49 +293,11 @@
(assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(assert (valid-methods? (::methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods] :as cfg}]
(let [public-uri (cf/get :public-uri)]
["/api"
["/management"
["/methods/:type"
{:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
[session/authz cfg]]
:handler (make-rpc-handler management-methods)}]
(doc/routes :methods management-methods
:label "management"
:base-uri (u/join public-uri "/api/management")
:description "MANAGEMENT API")]
["/main"
["/methods/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
:handler (make-rpc-handler methods)}]
(doc/routes :methods methods
:label "main"
:base-uri (u/join public-uri "/api/main")
:description "MAIN API")]
;; BACKWARD COMPATIBILITY
["/_doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
["/doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
["/rpc/command/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
:handler (make-rpc-handler methods)}]]))
[_ {:keys [::methods] :as cfg}]
(let [methods (update-vals methods peek)]
[["/rpc" {:middleware [[session/authz cfg]
[actoken/authz cfg]]}
["/command/:type" {:handler (partial rpc-handler methods)}]]]))

View File

@@ -28,7 +28,6 @@
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
token (tokens/generate cfg {:iss "access-token"
:uid profile-id
:iat created-at
:tid token-id})

View File

@@ -315,13 +315,16 @@
(-> (db/insert! conn :profile params)
(profile/decode-row))
(catch org.postgresql.util.PSQLException cause
(if (db/duplicate-key-error? cause)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause cause)
(throw cause))))))
(let [state (.getSQLState cause)]
(if (not= state "23505")
(throw cause)
(do
(l/error :hint "not an error" :cause cause)
(ex/raise :type :validation
:code :email-already-exists
:hint "email already exists"
:cause cause))))))))
(defn create-profile-rels!
[conn {:keys [id] :as profile}]

View File

@@ -25,10 +25,10 @@
[app.rpc.commands.projects :as projects]
[app.rpc.commands.teams :as teams]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.tasks.file-gc]
[app.util.services :as sv]
[app.worker :as-alias wrk]))
[app.worker :as-alias wrk]
[yetti.response :as yres]))
(set! *warn-on-reflection* true)
@@ -44,7 +44,7 @@
(defn stream-export-v1
[cfg {:keys [file-id include-libraries embed-assets] :as params}]
(rph/stream
(yres/stream-body
(fn [_ output-stream]
(try
(-> cfg
@@ -59,7 +59,7 @@
(defn stream-export-v3
[cfg {:keys [file-id include-libraries embed-assets] :as params}]
(rph/stream
(yres/stream-body
(fn [_ output-stream]
(try
(-> cfg
@@ -79,11 +79,16 @@
::sm/params schema:export-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id version file-id] :as params}]
(files/check-read-permissions! pool profile-id file-id)
(let [version (or version 1)]
(case (int version)
1 (stream-export-v1 cfg params)
2 (throw (ex-info "not-implemented" {}))
3 (stream-export-v3 cfg params))))
(fn [_]
(let [version (or version 1)
body (case (int version)
1 (stream-export-v1 cfg params)
2 (throw (ex-info "not-implemented" {}))
3 (stream-export-v3 cfg params))]
{::yres/status 200
::yres/headers {"content-type" "application/octet-stream"}
::yres/body body})))
;; --- Command: import-binfile

View File

@@ -234,39 +234,36 @@
(files/check-comment-permissions! conn profile-id file-id share-id)
(get-comment-threads conn profile-id file-id))))
(defn- get-comment-threads-sql
[where]
(str/ffmt
"SELECT DISTINCT ON (ct.id)
ct.*,
pf.fullname AS owner_fullname,
pf.email AS owner_email,
pf.photo_id AS owner_photo_id,
p.team_id AS team_id,
f.name AS file_name,
f.project_id AS project_id,
first_value(c.content) OVER w AS content,
(SELECT count(1)
FROM comment AS c
WHERE c.thread_id = ct.id) AS count_comments,
(SELECT count(1)
FROM comment AS c
WHERE c.thread_id = ct.id
AND c.created_at >= coalesce(cts.modified_at, ct.created_at)) AS count_unread_comments
FROM comment_thread AS ct
INNER JOIN comment AS c ON (c.thread_id = ct.id)
INNER JOIN file AS f ON (f.id = ct.file_id)
INNER JOIN project AS p ON (p.id = f.project_id)
LEFT JOIN comment_thread_status AS cts ON (cts.thread_id = ct.id AND cts.profile_id = ?)
LEFT JOIN profile AS pf ON (ct.owner_id = pf.id)
WHERE f.deleted_at IS NULL
AND p.deleted_at IS NULL
%1
WINDOW w AS (PARTITION BY c.thread_id ORDER BY c.created_at ASC)"
where))
(def ^:private sql:comment-threads
"SELECT DISTINCT ON (ct.id)
ct.*,
pf.fullname AS owner_fullname,
pf.email AS owner_email,
pf.photo_id AS owner_photo_id,
p.team_id AS team_id,
f.name AS file_name,
f.project_id AS project_id,
first_value(c.content) OVER w AS content,
(SELECT count(1)
FROM comment AS c
WHERE c.thread_id = ct.id) AS count_comments,
(SELECT count(1)
FROM comment AS c
WHERE c.thread_id = ct.id
AND c.created_at >= coalesce(cts.modified_at, ct.created_at)) AS count_unread_comments
FROM comment_thread AS ct
INNER JOIN comment AS c ON (c.thread_id = ct.id)
INNER JOIN file AS f ON (f.id = ct.file_id)
INNER JOIN project AS p ON (p.id = f.project_id)
LEFT JOIN comment_thread_status AS cts ON (cts.thread_id = ct.id AND cts.profile_id = ?)
LEFT JOIN profile AS pf ON (ct.owner_id = pf.id)
WHERE f.deleted_at IS NULL
AND p.deleted_at IS NULL
WINDOW w AS (PARTITION BY c.thread_id ORDER BY c.created_at ASC)")
(def ^:private sql:comment-threads-by-file-id
(get-comment-threads-sql "AND ct.file_id = ?"))
(str "WITH threads AS (" sql:comment-threads ")"
"SELECT * FROM threads WHERE file_id = ?"))
(defn- get-comment-threads
[conn profile-id file-id]
@@ -276,29 +273,34 @@
;; --- COMMAND: Get Unread Comment Threads
(def ^:private sql:unread-all-comment-threads-by-team
(str "WITH threads AS ("
(get-comment-threads-sql "AND p.team_id = ?")
")"
"SELECT t.* FROM threads AS t
WHERE t.count_unread_comments > 0"))
(str "WITH threads AS (" sql:comment-threads ")"
"SELECT * FROM threads WHERE count_unread_comments > 0 AND team_id = ?"))
;; The partial configuration will retrieve only comments created by the user and
;; threads that have a mention to the user.
(def ^:private sql:unread-partial-comment-threads-by-team
(str "WITH threads AS ("
(get-comment-threads-sql "AND p.team_id = ? AND (ct.owner_id = ? OR ? = ANY(ct.mentions))")
")"
"SELECT t.* FROM threads AS t
WHERE t.count_unread_comments > 0"))
(str "WITH threads AS (" sql:comment-threads ")"
"SELECT * FROM threads
WHERE count_unread_comments > 0
AND team_id = ?
AND (owner_id = ? OR ? = ANY(mentions))"))
(defn- get-unread-comment-threads
[cfg profile-id team-id]
(let [profile (-> (db/get cfg :profile {:id profile-id} ::db/remove-deleted false)
(let [profile (-> (db/get cfg :profile {:id profile-id})
(profile/decode-row))
notify (or (-> profile :props :notifications :dashboard-comments) :all)
result (case notify
:all (db/exec! cfg [sql:unread-all-comment-threads-by-team profile-id team-id])
:partial (db/exec! cfg [sql:unread-partial-comment-threads-by-team profile-id team-id profile-id profile-id])
[])]
(into [] xf-decode-row result)))
notify (or (-> profile :props :notifications :dashboard-comments) :all)]
(case notify
:all
(->> (db/exec! cfg [sql:unread-all-comment-threads-by-team profile-id team-id])
(into [] xf-decode-row))
:partial
(->> (db/exec! cfg [sql:unread-partial-comment-threads-by-team profile-id team-id profile-id profile-id])
(into [] xf-decode-row))
[])))
(def ^:private
schema:get-unread-comment-threads
@@ -321,17 +323,16 @@
[:id ::sm/uuid]
[:share-id {:optional true} [:maybe ::sm/uuid]]])
(def ^:private sql:get-comment-thread
(get-comment-threads-sql "AND ct.file_id = ? AND ct.id = ?"))
(sv/defmethod ::get-comment-thread
{::doc/added "1.15"
::sm/params schema:get-comment-thread}
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(files/check-comment-permissions! conn profile-id file-id share-id)
(some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row)))))
(let [sql (str "WITH threads AS (" sql:comment-threads ")"
"SELECT * FROM threads WHERE id = ? AND file_id = ?")]
(-> (db/exec-one! conn [sql profile-id id file-id])
(decode-row))))))
;; --- COMMAND: Retrieve Comments

View File

@@ -45,7 +45,6 @@
params {:email email
:fullname fullname
:is-active true
:is-demo true
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:props {}}

View File

@@ -7,7 +7,6 @@
(ns app.rpc.commands.feedback
"A general purpose feedback module."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.config :as cf]
@@ -22,11 +21,8 @@
(def ^:private schema:send-user-feedback
[:map {:title "send-user-feedback"}
[:subject [:string {:max 500}]]
[:content [:string {:max 2500}]]
[:type {:optional true} :string]
[:error-href {:optional true} [:string {:max 2500}]]
[:error-report {:optional true} :string]])
[:subject [:string {:max 400}]]
[:content [:string {:max 2500}]]])
(sv/defmethod ::send-user-feedback
{::doc/added "1.18"
@@ -43,26 +39,16 @@
(defn- send-user-feedback!
[pool profile params]
(let [destination
(or (cf/get :user-feedback-destination)
;; LEGACY
(cf/get :feedback-destination))
attachments
(d/without-nils
{"error-report.txt" (:error-report params)})]
(let [dest (or (cf/get :user-feedback-destination)
;; LEGACY
(cf/get :feedback-destination))]
(eml/send! {::eml/conn pool
::eml/factory eml/user-feedback
:from (cf/get :smtp-default-from)
:to destination
:from dest
:to dest
:profile profile
:reply-to (:email profile)
:email (:email profile)
:attachments attachments
:feedback-subject (:subject params)
:feedback-type (:type params "not-specified")
:feedback-content (:content params)
:feedback-error-href (:error-href params)
:profile profile})
:subject (:subject params)
:content (:content params)})
nil))

View File

@@ -26,7 +26,6 @@
[app.db.sql :as-alias sql]
[app.features.fdata :as feat.fdata]
[app.features.logical-deletion :as ldel]
[app.http.sse :as sse]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
[app.msgbus :as mbus]
@@ -39,7 +38,6 @@
[app.rpc.helpers :as rph]
[app.rpc.permissions :as perms]
[app.util.blob :as blob]
[app.util.events :as events]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
[app.worker :as wrk]
@@ -355,8 +353,9 @@
::sm/params schema:get-project-files
::sm/result schema:files}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
(projects/check-read-permissions! pool profile-id project-id)
(get-project-files pool project-id))
(dm/with-open [conn (db/open pool)]
(projects/check-read-permissions! conn profile-id project-id)
(get-project-files conn project-id)))
;; --- COMMAND QUERY: has-file-libraries
@@ -425,6 +424,7 @@
;; --- QUERY COMMAND: get-page
(defn- prune-objects
"Given the page data and the object-id returns the page data with all
other not needed objects removed from the `:objects` data
@@ -765,54 +765,6 @@
(teams/check-read-permissions! conn profile-id team-id)
(get-team-recent-files conn team-id)))
;; --- COMMAND QUERY: get-team-deleted-files
(def sql:team-deleted-files
"WITH deleted_files AS (
SELECT f.id,
f.revn,
f.vern,
f.project_id,
f.created_at,
f.modified_at,
f.name,
f.is_shared,
f.deleted_at AS will_be_deleted_at,
ft.media_id AS thumbnail_id,
row_number() OVER w AS row_num,
p.team_id
FROM file AS f
INNER JOIN project AS p ON (p.id = f.project_id)
LEFT JOIN file_thumbnail AS ft on (ft.file_id = f.id
AND ft.revn = f.revn
AND ft.deleted_at is null)
WHERE p.team_id = ?
AND (p.deleted_at > ?::timestamptz OR
f.deleted_at > ?::timestamptz)
WINDOW w AS (PARTITION BY f.project_id
ORDER BY f.modified_at DESC)
ORDER BY f.modified_at DESC
)
SELECT * FROM deleted_files")
(defn get-team-deleted-files
[conn team-id]
(let [now (ct/now)]
(db/exec! conn [sql:team-deleted-files team-id now now])))
(def ^:private schema:get-team-deleted-files
[:map {:title "get-team-deleted-files"}
[:team-id ::sm/uuid]])
(sv/defmethod ::get-team-deleted-files
{::doc/added "2.12"
::sm/params schema:get-team-deleted-files}
[cfg {:keys [::rpc/profile-id team-id]}]
(db/run! cfg (fn [{:keys [::db/conn]}]
(teams/check-read-permissions! conn profile-id team-id)
(get-team-deleted-files conn team-id))))
;; --- COMMAND QUERY: get-file-info
@@ -1161,118 +1113,3 @@
(check-edition-permissions! conn profile-id file-id)
(-> (ignore-sync conn params)
(update :features db/decode-pgarray #{})))
;; --- MUTATION COMMAND: delete-files-immediatelly
(def ^:private sql:delete-team-files
"UPDATE file AS uf SET deleted_at = ?::timestamptz
FROM (
SELECT f.id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
JOIN team AS t ON (t.id = p.team_id)
WHERE t.deleted_at IS NULL
AND t.id = ?
AND f.id = ANY(?::uuid[])
) AS subquery
WHERE uf.id = subquery.id
RETURNING uf.id, uf.deleted_at;")
(def ^:private schema:permanently-delete-team-files
[:map {:title "permanently-delete-team-files"}
[:team-id ::sm/uuid]
[:ids [::sm/set ::sm/uuid]]])
(sv/defmethod ::permanently-delete-team-files
"Mark the specified files to be deleted immediatelly on the
specified team. The team-id on params will be used to filter and
check writable permissons on team."
{::doc/added "2.12"
::sm/params schema:permanently-delete-team-files
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id ::rpc/request-at team-id ids]}]
(teams/check-edition-permissions! conn profile-id team-id)
(reduce (fn [acc {:keys [id deleted-at]}]
(wrk/submit! {::db/conn conn
::wrk/task :delete-object
::wrk/params {:object :file
:deleted-at deleted-at
:id id}})
(conj acc id))
#{}
(db/plan conn [sql:delete-team-files request-at team-id
(db/create-array conn "uuid" ids)])))
;; --- MUTATION COMMAND: restore-files-immediatelly
(def ^:private sql:resolve-editable-files
"SELECT f.id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
JOIN team AS t ON (t.id = p.team_id)
WHERE t.deleted_at IS NULL
AND t.id = ?
AND f.id = ANY(?::uuid[])")
(defn- restore-file
[conn file-id]
(db/update! conn :file
{:deleted-at nil
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
(db/update! conn :file-media-object
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-change
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-data
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-tagged-object-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false}))
(defn- restore-deleted-team-files
[{:keys [::db/conn]} {:keys [::rpc/profile-id team-id ids]}]
(teams/check-edition-permissions! conn profile-id team-id)
(reduce (fn [affected {:keys [id]}]
(let [index (inc (count affected))]
(events/tap :progress {:file-id id :index index :total (count ids)})
(restore-file conn id)
(conj affected id)))
#{}
(db/plan conn [sql:resolve-editable-files team-id
(db/create-array conn "uuid" ids)])))
(def ^:private schema:restore-deleted-team-files
[:map {:title "restore-deleted-team-files"}
[:team-id ::sm/uuid]
[:ids [::sm/set ::sm/uuid]]])
(sv/defmethod ::restore-deleted-team-files
"Removes the deletion mark from the specified files (and respective projects)."
{::doc/added "2.12"
::sse/stream? true
::sm/params schema:restore-deleted-team-files}
[cfg params]
(sse/response #(db/tx-run! cfg restore-deleted-team-files params)))

View File

@@ -18,7 +18,6 @@
[app.db.sql :as-alias sql]
[app.email :as eml]
[app.http.session :as session]
[app.nitrate :as nitrate]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
@@ -99,23 +98,16 @@
;; no profile-id is in session, and when db call raises not found. In all other
;; cases we need to reraise the exception.
(try
(let [nitrate (get cfg ::nitrate/instance)
;; org ((get nitrate :get-organization) profile-id)
;; org (nitrate/call cfg :get-organization {:profile-id profile-id})
(-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props)))
(-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))
(catch Throwable _
{:id uuid/zero :fullname "Anonymous User"})))
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}]
;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
;; are created with a set deleted-at value
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
(-> (db/get-by-id conn :profile id opts)
(decode-row)))
;; --- MUTATION: Update Profile (own)
@@ -481,16 +473,13 @@
p.fullname AS name,
p.email AS email
FROM team_profile_rel AS tpr1
JOIN team as t
ON tpr1.team_id = t.id
JOIN team_profile_rel AS tpr2
ON (tpr1.team_id = tpr2.team_id)
JOIN profile AS p
ON (tpr2.profile_id = p.id)
WHERE tpr1.profile_id = ?
AND tpr1.is_owner IS true
AND tpr2.can_edit IS true
AND t.deleted_at IS NULL")
AND tpr2.can_edit IS true")
(sv/defmethod ::get-subscription-usage
{::doc/added "2.9"}

View File

@@ -70,27 +70,7 @@
;; --- QUERY: Get projects
(def ^:private sql:projects
"SELECT p.*,
coalesce(tpp.is_pinned, false) as is_pinned,
(SELECT count(*) FROM file AS f
WHERE f.project_id = p.id
AND f.deleted_at is null) AS count,
(SELECT count(*) FROM file AS f
WHERE f.project_id = p.id) AS total_count
FROM project AS p
INNER JOIN team AS t ON (t.id = p.team_id)
LEFT JOIN team_project_profile_rel AS tpp
ON (tpp.project_id = p.id AND
tpp.team_id = p.team_id AND
tpp.profile_id = ?)
WHERE p.team_id = ?
AND t.deleted_at is null
ORDER BY p.modified_at DESC")
(defn get-projects
[conn profile-id team-id]
(db/exec! conn [sql:projects profile-id team-id]))
(declare get-projects)
(def ^:private schema:get-projects
[:map {:title "get-projects"}
@@ -98,11 +78,32 @@
(sv/defmethod ::get-projects
{::doc/added "1.18"
::doc/changes [["2.12" "This endpoint now return deleted but recoverable projects"]]
::sm/params schema:get-projects}
[cfg {:keys [::rpc/profile-id team-id]}]
(teams/check-read-permissions! cfg profile-id team-id)
(get-projects cfg profile-id team-id))
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id]}]
(dm/with-open [conn (db/open pool)]
(teams/check-read-permissions! conn profile-id team-id)
(get-projects conn profile-id team-id)))
(def sql:projects
"select p.*,
coalesce(tpp.is_pinned, false) as is_pinned,
(select count(*) from file as f
where f.project_id = p.id
and deleted_at is null) as count
from project as p
inner join team as t on (t.id = p.team_id)
left join team_project_profile_rel as tpp
on (tpp.project_id = p.id and
tpp.team_id = p.team_id and
tpp.profile_id = ?)
where p.team_id = ?
and p.deleted_at is null
and t.deleted_at is null
order by p.modified_at desc")
(defn get-projects
[conn profile-id team-id]
(db/exec! conn [sql:projects profile-id team-id]))
;; --- QUERY: Get all projects

View File

@@ -37,14 +37,14 @@
;; --- Helpers & Specs
(def ^:private sql:team-permissions
"SELECT tpr.is_owner,
"select tpr.is_owner,
tpr.is_admin,
tpr.can_edit
FROM team_profile_rel AS tpr
JOIN team AS t ON (t.id = tpr.team_id)
WHERE tpr.profile_id = ?
AND tpr.team_id = ?
AND t.deleted_at IS NULL")
from team_profile_rel as tpr
join team as t on (t.id = tpr.team_id)
where tpr.profile_id = ?
and tpr.team_id = ?
and t.deleted_at is null")
(defn get-permissions
[conn profile-id team-id]

View File

@@ -16,7 +16,6 @@
[app.common.schema.desc-native :as smdn]
[app.common.schema.openapi :as oapi]
[app.common.schema.registry :as sr]
[app.common.uri :as u]
[app.config :as cf]
[app.http.sse :as-alias sse]
[app.loggers.webhooks :as-alias webhooks]
@@ -26,6 +25,7 @@
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[pretty-spec.core :as ps]
[yetti.response :as-alias yres]))
@@ -33,8 +33,8 @@
;; DOC (human readable)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- context
[{:keys [methods entrypoint label openapi]}]
(defn- prepare-doc-context
[methods]
(letfn [(fmt-spec [mdata]
(when-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))]
(with-out-str
@@ -62,10 +62,8 @@
:added (::added mdata)
:changes (some->> (::changes mdata) (partition-all 2) (map vec))
:spec (fmt-spec mdata)
:entrypoint (-> entrypoint
(u/ensure-path-slash)
(u/join (::sv/name mdata))
(str))
:entrypoint (str (cf/get :public-uri) "/api/rpc/command/" (::sv/name mdata))
:params-schema-js (fmt-schema :js mdata ::sm/params)
:result-schema-js (fmt-schema :js mdata ::sm/result)
:webhook-schema-js (fmt-schema :js mdata ::sm/webhook)
@@ -74,9 +72,6 @@
:webhook-schema-clj (fmt-schema :clj mdata ::sm/webhook)})]
{:version (:main cf/version)
:label label
:entrypoint (str entrypoint)
:openapi (str openapi)
:methods
(->> methods
(map val)
@@ -85,19 +80,17 @@
(map get-context)
(sort-by (juxt :module :name)))}))
(defn- handler
[& {:keys [template] :as options}]
(defn- doc-handler
[context]
(if (contains? cf/flags :backend-api-doc)
(let [context (delay (context options))
template (or template "app/templates/api-doc.tmpl")]
(fn [request]
(let [params (:query-params request)
pstyle (:type params "js")
context (assoc @context :param-style pstyle)]
(fn [request]
(let [params (:query-params request)
pstyle (:type params "js")
context (assoc @context :param-style pstyle)]
{::yres/status 200
::yres/body (-> (io/resource template)
(tmpl/render context))})))
{::yres/status 200
::yres/body (-> (io/resource "app/templates/api-doc.tmpl")
(tmpl/render context))}))
(fn [_]
{::yres/status 404})))
@@ -105,8 +98,8 @@
;; OPENAPI / SWAGGER (v3.1)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- openapi-context
[{:keys [methods entrypoint description]}]
(defn prepare-openapi-context
[methods]
(let [definitions (atom {})
options {:registry sr/default-registry
::oapi/definitions-path "#/components/schemas/"
@@ -119,9 +112,7 @@
(fn [tsx schema]
(let [schema (sm/schema schema)
example (sm/generate schema)
example (sm/encode schema example output-transformer)
example (json/encode example :key-fn json/write-camel-key)]
example (sm/encode schema example output-transformer)]
{:default
{:description "A default response"
:content
@@ -132,9 +123,7 @@
gen-params-doc
(fn [tsx schema]
(let [example (sm/generate schema)
example (sm/encode schema example output-transformer)
example (json/encode example :key-fn json/write-camel-key)]
example (sm/encode schema example output-transformer)]
{:required true
:content
{"application/json"
@@ -169,35 +158,34 @@
(map gen-method-doc)
(sort-by (juxt :module :name))
(map (fn [doc]
[(:name doc) (:repr doc)]))
[(str/ffmt "/command/%" (:name doc)) (:repr doc)]))
(into {})))]
{:openapi "3.0.0"
:info {:version (:main cf/version)}
:servers [{:url (str entrypoint)
:description (or description "")}]
:servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri))
;; :description "penpot backend"
}]
:paths paths
:components {:schemas @definitions}}))
(defn- openapi-json-handler
[& {:as options}]
(defn openapi-json-handler
[context]
(if (contains? cf/flags :backend-openapi-doc)
(let [context (delay (openapi-context options))]
(fn [_]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode @context)}))
(fn [_]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode @context)})
(fn [_]
{::yres/status 404})))
(defn- openapi-handler
[& {:keys [uri label]}]
(defn openapi-handler
[]
(if (contains? cf/flags :backend-openapi-doc)
(fn [_]
(let [swagger-js (slurp (io/resource "app/assets/swagger-ui-4.18.3.js"))
swagger-cs (slurp (io/resource "app/assets/swagger-ui-4.18.3.css"))
context {:uri (str uri)
:label label
context {:public-uri (cf/get :public-uri)
:swagger-js swagger-js
:swagger-css swagger-cs}]
{::yres/status 200
@@ -208,43 +196,27 @@
{::yres/status 404})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ROUTES HELPER
;; MODULE INIT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn routes
[& {:keys [label base-uri description methods]}]
(let [entrypoint
(-> base-uri
(u/ensure-path-slash)
(u/join "methods"))
(defmethod ig/assert-key ::routes
[_ params]
(assert (sm/valid? ::rpc/methods (::rpc/methods params)) "expected valid methods"))
openapi
(-> base-uri
(u/ensure-path-slash)
(u/join "doc/openapi"))
(defmethod ig/init-key ::routes
[_ {:keys [::rpc/methods] :as cfg}]
[(let [context (delay (prepare-doc-context methods))]
[["/_doc"
{:handler (doc-handler context)
:allowed-methods #{:get}}]
["/doc"
{:handler (doc-handler context)
:allowed-methods #{:get}}]])
template
(case label
"management" "app/templates/management-api-doc.tmpl"
"main" "app/templates/main-api-doc.tmpl")]
["/doc"
["" {:handler (handler :methods methods
:label label
:entrypoint entrypoint
:openapi openapi
:template template)
:allowed-methods #{:get}}]
["/openapi"
{:handler (openapi-handler
:uri (u/join openapi "openapi.json")
:label label)
:allowed-methods #{:get}}]
["/openapi.json"
{:handler (openapi-json-handler {:entrypoint entrypoint
:description description
:methods methods})
:allowed-methods #{:get}}]]))
(let [context (delay (prepare-openapi-context methods))]
[["/openapi"
{:handler (openapi-handler)
:allowed-methods #{:get}}]
["/openapi.json"
{:handler (openapi-json-handler context)
:allowed-methods #{:get}}]])])

View File

@@ -11,7 +11,7 @@
[app.common.data.macros :as dm]
[app.http :as-alias http]
[app.rpc :as-alias rpc]
[yetti.response :as yres]))
[yetti.response :as-alias yres]))
;; A utilty wrapper object for wrap service responses that does not
;; implements the IObj interface that make possible attach metadata to
@@ -78,8 +78,3 @@
(let [exp (if (integer? max-age) max-age (inst-ms max-age))
val (dm/fmt "max-age=%" (int (/ exp 1000.0)))]
(update response ::yres/headers assoc "cache-control" val)))))
(defn stream
"A convenience allias for yetti.response/stream-body"
[f]
(yres/stream-body f))

View File

@@ -1,183 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.subscription
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- RPC METHOD: AUTHENTICATE
(def ^:private
schema:authenticate-params
[:map {:title "authenticate-params"}])
(def ^:private
schema:authenticate-result
[:map {:title "authenticate-result"}
[:profile-id ::sm/uuid]])
(sv/defmethod ::auth
{::doc/added "2.12"
::sm/params schema:authenticate-params
::sm/result schema:authenticate-result}
[_ {:keys [::rpc/profile-id]}]
{:profile-id profile-id})
;; ---- RPC METHOD: GET-CUSTOMER
;; FIXME: move to app.common.time
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string #(some-> % ct/inst)
:encode/string #(some-> % inst-ms)
:decode/json #(some-> % ct/inst)
:encode/json #(some-> % inst-ms)}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[cfg profile-id]
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
(:total result)))
(def ^:private schema:get-customer-params
[:map])
(def ^:private schema:get-customer-result
[:map
[:id ::sm/uuid]
[:name :string]
[:num-editors ::sm/int]
[:subscription {:optional true} schema:subscription]])
(sv/defmethod ::get-customer
{::doc/added "2.12"
::sm/params schema:get-customer-params
::sm/result schema:get-customer-result}
[cfg {:keys [::rpc/profile-id]}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots cfg profile-id)
:subscription (-> profile :props :subscription)}))
;; ---- RPC METHOD: GET-CUSTOMER
(def ^:private schema:update-customer-params
[:map
[:subscription [:maybe schema:subscription]]])
(def ^:private schema:update-customer-result
[:map])
(sv/defmethod ::update-customer
{::doc/added "2.12"
::sm/params schema:update-customer-params
::sm/result schema:update-customer-result}
[cfg {:keys [::rpc/profile-id subscription]}]
(let [{:keys [props] :as profile}
(profile/get-profile cfg profile-id ::db/for-update true)
props
(assoc props :subscription subscription)]
(l/dbg :hint "update customer"
:profile-id (str profile-id)
:subscription-type (get subscription :type)
:subscription-status (get subscription :status)
:subscription-quantity (get subscription :quantity))
(db/update! cfg :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))

View File

@@ -102,7 +102,8 @@
::wrk/label "quotes-notification"
::wrk/params {:to (vec admins)
:subject subject
:body content}}))))
:body [{:type "text/plain"
:content content}]}}))))
(defn- generic-check!
[{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}]

View File

@@ -14,9 +14,7 @@
[integrant.core :as ig])
(:import
java.time.Clock
java.time.Duration
java.time.Instant
java.time.ZoneId))
java.time.Duration))
(defonce current
(atom {:clock (Clock/systemDefaultZone)
@@ -38,12 +36,6 @@
[_ _]
(remove-watch current ::common))
(defn fixed
"Get fixed clock, mainly used in tests"
[instant]
(Clock/fixed ^Instant (ct/inst instant)
^ZoneId (ZoneId/of "Z")))
(defn set-offset!
[duration]
(swap! current assoc :offset (some-> duration ct/duration)))

View File

@@ -567,12 +567,48 @@
:id file-id})))
:deleted))
(defn- restore-file*
[{:keys [::db/conn]} file-id]
(db/update! conn :file
{:deleted-at nil
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
(db/update! conn :file-media-object
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-change
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-data
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
;; Mark thumbnails to be deleted
(db/update! conn :file-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
(db/update! conn :file-tagged-object-thumbnail
{:deleted-at nil}
{:file-id file-id}
{::db/return-keys false})
:restored)
(defn restore-file!
"Mark a file and all related objects as not deleted"
[file-id]
(let [file-id (h/parse-uuid file-id)]
(db/tx-run! main/system
(fn [{:keys [::db/conn] :as system}]
(fn [system]
(when-let [file (db/get* system :file
{:id file-id}
{::db/remove-deleted false
@@ -586,9 +622,7 @@
:cause "explicit call to restore-file!"}
::audit/tracked-at (ct/now)})
(#'files/restore-file conn file-id))
:restored))))
(restore-file* system file-id))))))
(defn delete-project!
"Mark a project for deletion"
@@ -621,7 +655,7 @@
(doseq [{:keys [id]} (db/query conn :file
{:project-id project-id}
{::sql/columns [:id]})]
(#'files/restore-file conn id))
(restore-file* cfg id))
:restored)

View File

@@ -218,9 +218,6 @@
(when (or (nil? revn) (= revn (:revn file)))
file)))
;; FIXME: we should skip files that does not match the revn on the
;; props and add proper schema for this task props
(defn- process-file!
[cfg {:keys [file-id] :as props}]
(if-let [file (get-file cfg props)]

View File

@@ -8,7 +8,6 @@
"A maintenance task that is responsible of properly scheduling the
file-gc task for all files that matches the eligibility threshold."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
@@ -22,24 +21,25 @@
f.modified_at
FROM file AS f
WHERE f.has_media_trimmed IS false
AND f.modified_at < ?
AND f.modified_at < now() - ?::interval
AND f.deleted_at IS NULL
ORDER BY f.modified_at DESC
FOR UPDATE OF f
SKIP LOCKED")
(defn- get-candidates
[{:keys [::db/conn ::min-age] :as cfg}]
(let [min-age (db/interval min-age)]
(db/plan conn [sql:get-candidates min-age] {:fetch-size 10})))
(defn- schedule!
[{:keys [::db/conn] :as cfg} threshold]
[cfg]
(let [total (reduce (fn [total {:keys [id modified-at revn]}]
(let [params {:file-id id :revn revn}]
(l/trc :hint "schedule"
:file-id (str id)
:revn revn
:modified-at (ct/format-inst modified-at))
(let [params {:file-id id :modified-at modified-at :revn revn}]
(wrk/submit! (assoc cfg ::wrk/params params))
(inc total)))
0
(db/plan conn [sql:get-candidates threshold] {:fetch-size 10}))]
(get-candidates cfg))]
{:processed total}))
(defmethod ig/assert-key ::handler
@@ -53,12 +53,12 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props] :as task}]
(let [threshold (-> (ct/duration (or (:min-age props) (::min-age cfg)))
(ct/in-past))]
(let [min-age (ct/duration (or (:min-age props) (::min-age cfg)))]
(-> cfg
(assoc ::db/rollback (:rollback? props))
(assoc ::min-age min-age)
(assoc ::wrk/task :file-gc)
(assoc ::wrk/priority 10)
(assoc ::wrk/mark-retries 0)
(assoc ::wrk/delay 10000)
(db/tx-run! schedule! threshold)))))
(assoc ::wrk/delay 1000)
(db/tx-run! schedule!)))))

View File

@@ -9,7 +9,7 @@
[app.common.exceptions :as ex]
[selmer.parser :as sp]))
(sp/cache-off!)
;; (sp/cache-off!)
(defn render
[path context]

View File

@@ -77,8 +77,8 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:insert-new-task
"insert into task (id, name, props, queue, label, priority, max_retries, created_at, modified_at, scheduled_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"insert into task (id, name, props, queue, label, priority, max_retries, scheduled_at)
values (?, ?, ?, ?, ?, ?, ?, now() + ?)
returning id")
(def ^:private
@@ -88,7 +88,7 @@
AND queue=?
AND label=?
AND status = 'new'
AND scheduled_at > ?")
AND scheduled_at > now()")
(def ^:private schema:options
[:map {:title "submit-options"}
@@ -111,19 +111,17 @@
(check-options! options)
(let [delay (ct/duration delay)
now (ct/now)
scheduled-at (-> (ct/plus now delay)
(ct/truncate :millisecond))
props (db/tjson params)
id (uuid/next)
tenant (cf/get :tenant)
task (d/name task)
queue (str/ffmt "%:%" tenant (d/name queue))
conn (db/get-connectable options)
deleted (when dedupe
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label now])
(db/get-update-count)))]
(let [duration (ct/duration delay)
interval (db/interval duration)
props (db/tjson params)
id (uuid/next)
tenant (cf/get :tenant)
task (d/name task)
queue (str/ffmt "%:%" tenant (d/name queue))
conn (db/get-connectable options)
deleted (when dedupe
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label])
:next.jdbc/update-count))]
(l/trc :hint "submit task"
:name task
@@ -131,13 +129,11 @@
:queue queue
:label label
:dedupe (boolean dedupe)
:delay (ct/format-duration delay)
:delay (ct/format-duration duration)
:replace (or deleted 0))
(db/exec-one! conn [sql:insert-new-task id task props queue
label priority max-retries
now now scheduled-at])
label priority max-retries interval])
id))
(defn invoke!

View File

@@ -13,7 +13,6 @@
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.transit :as t]
[app.config :as cf]
[app.db :as db]
[app.metrics :as mtx]
[app.redis :as rds]
@@ -61,8 +60,7 @@
(defn get-error-context
[_ item]
(-> (cf/logging-context)
(assoc :params item)))
{:params item})
(defn- get-task
[{:keys [::db/pool]} task-id]
@@ -133,11 +131,6 @@
[{:keys [::id ::timeout] :as cfg} task-id scheduled-at]
(loop [task (get-task cfg task-id)]
(cond
(nil? task)
(l/wrn :hint "no task found on the database"
:runner-id id
:task-id task-id)
(ex/exception? task)
(if (or (db/connection-error? task)
(db/serialization-error? task))
@@ -158,9 +151,12 @@
(inst-ms (:scheduled-at task)))
(l/wrn :hint "skiping task, rescheduled"
:task-id task-id
:runner-id id)
(nil? task)
(l/wrn :hint "no task found on the database"
:runner-id id
:scheduled-at (ct/format-inst (:scheduled-at task))
:expected-scheduled-at (ct/format-inst scheduled-at))
:task-id task-id)
:else
(let [result (run-task cfg task)]
@@ -181,8 +177,7 @@
{:error explain
:status "retry"
:modified-at now
:scheduled-at (-> (ct/plus now delay)
(ct/truncate :millisecond))
:scheduled-at (ct/plus now delay)
:retry-num nretry}
{:id (:id task)})
nil))
@@ -218,7 +213,6 @@
:payload payload)))
(catch Throwable cause
(l/err :hint "unable to decode payload"
::l/context (cf/logging-context)
:payload payload
:length (alength ^String/1 payload)
:cause cause))))
@@ -230,11 +224,11 @@
"failed" (handle-task-failure result)
"completed" (handle-task-completion result)
(throw (IllegalArgumentException.
(str "invalid status received: '" status "'"))))))
(str "invalid status received: " status))))))
(run-task-loop [[task-id scheduled-at]]
(loop [result (run-task! cfg task-id scheduled-at)]
(when-let [cause (some-> result process-result)]
(when-let [cause (process-result result)]
(if (or (db/connection-error? cause)
(db/serialization-error? cause))
(do
@@ -242,9 +236,9 @@
:cause cause)
(px/sleep timeout)
(recur result))
(l/err :hint "unhandled exception on processing task result"
::l/context (cf/logging-context)
:cause cause)))))]
(do
(l/err :hint "unhandled exception on processing task result"
:cause cause))))))]
(try
(let [key (str/ffmt "penpot.worker.queue:%" queue)
@@ -260,14 +254,11 @@
(if (rds/timeout-exception? cause)
(do
(l/err :hint "redis pop operation timeout, consider increasing redis timeout (will retry in some instants)"
::l/context (cf/logging-context)
:timeout timeout
:cause cause)
(px/sleep timeout))
(l/err :hint "unhandled exception"
::l/context (cf/logging-context)
:cause cause))))))
(l/err :hint "unhandled exception" :cause cause))))))
(defn- start-thread!
[{:keys [::id ::queue ::wrk/tenant] :as cfg}]
@@ -293,7 +284,6 @@
:queue queue))
(catch Throwable cause
(l/err :hint "unexpected exception"
::l/context (cf/logging-context)
:id id
:queue queue
:cause cause))

View File

@@ -22,4 +22,4 @@
(t/is (contains? result :body))
(t/is (contains? result :to))
#_(t/is (contains? result :reply-to))
(t/is (map? (:body result)))))
(t/is (vector? (:body result)))))

View File

@@ -549,44 +549,6 @@
(io/copy r sw)
(.toString sw))))
(defn parse-sse
[content]
(let [state
(reduce (fn [{:keys [events data event id] :as state} line]
(cond
;; empty line → dispatch event if we have data
(str/blank? line)
(if (seq data)
(-> state
(update :events conj {:event (or event "message")
:data (-> (str/join "\n" data))})
(assoc :data [] :event nil))
state)
;; comment line (starts with :)
(str/starts-with? line ":")
state
:else
(let [[field raw-value] (str/split line #":" 2)
value (some-> raw-value (str/replace #"^ " ""))]
(case field
"data" (update state :data conj (or value ""))
"event" (assoc state :event value)
;; ignore retry and unknown fields
state))))
{:events [] :data [] :event nil}
(str/split content #"\r?\n"))
;; handle unterminated last event (no trailing blank line)
state (if (seq (:data state))
(update state :events conj
{:event (or (:event state) "message")
:data (str/join "\n" (:data state))})
state)]
(:events state)))
(defn consume-sse
[callback]
(let [{:keys [::yres/status ::yres/body ::yres/headers] :as response} (callback {})
@@ -596,9 +558,12 @@
(try
(px/exec! :virtual #(rcp/write-body-to-stream body nil output))
(into []
(map (fn [{:keys [event data]}]
[(keyword event)
(tr/decode-str data)]))
(parse-sse (slurp' input)))
(map (fn [event]
(let [[item1 item2] (re-seq #"(.*): (.*)\n?" event)]
[(keyword (nth item1 2))
(tr/decode-str (nth item2 2))])))
(-> (slurp' input)
(str/split "\n\n")))
(finally
(.close input)))))

View File

@@ -0,0 +1,57 @@
;; 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.http-middleware-access-token-test
(:require
[app.db :as db]
[app.http.access-token]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest soft-auth-middleware
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! nil)
handler (#'app.http.access-token/wrap-soft-auth
(fn [req] (vreset! request req))
th/*system*)]
(with-mocks [m1 {:target 'app.http.access-token/get-token
:return nil}]
(handler {})
(t/is (= {} @request)))
(with-mocks [m1 {:target 'app.http.access-token/get-token
:return (:token token)}]
(handler {})
(let [token-id (get @request :app.http.access-token/id)]
(t/is (= token-id (:id token)))))))
(t/deftest authz-middleware
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! {})
handler (#'app.http.access-token/wrap-authz
(fn [req] (vreset! request req))
th/*system*)]
(handler nil)
(t/is (nil? @request))
(handler {:app.http.access-token/id (:id token)})
(t/is (= #{} (:app.http.access-token/perms @request)))
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))

View File

@@ -1,161 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.http-middleware-test
(:require
[app.common.time :as ct]
[app.db :as db]
[app.http.access-token]
[app.http.auth :as-alias auth]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.request :as yreq]
[yetti.response :as yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name)))
(t/deftest auth-middleware-1
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
(t/is (= :token (::auth/token-type @request)))
(t/is (= "aaaa" (::auth/token @request)))))
(t/deftest auth-middleware-2
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
(t/is (= :bearer (::auth/token-type @request)))
(t/is (= "aaaa" (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))))
(t/deftest auth-middleware-3
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(t/is (= :cookie (::auth/token-type @request)))
(t/is (= "foobar" (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))))
(t/deftest auth-middleware-4
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{:cookie (fn [_] "foobaz")})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(t/is (= :cookie (::auth/token-type @request)))
(t/is (= "foobar" (::auth/token @request)))
(t/is (delay? (::auth/claims @request)))
(t/is (= "foobaz" (-> @request ::auth/claims deref)))))
(t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200})
"secret-key")]
(let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! {})
handler (#'app.http.access-token/wrap-authz
(fn [req] (vreset! request req))
th/*system*)]
(handler nil)
(t/is (nil? @request))
(handler {::auth/claims (delay {:tid (:id token)})
::auth/token-type :token})
(t/is (= #{} (:app.http.access-token/perms @request)))
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))
(t/deftest session-authz
(let [manager (session/inmemory-manager)
profile (th/create-profile* 1)
handler (-> (fn [req] req)
(#'session/wrap-authz {::session/manager manager})
(#'mw/wrap-auth {}))]
(let [response (handler (->DummyRequest {} {"auth-token" "foobar"}))]
(t/is (= :cookie (::auth/token-type response)))
(t/is (= "foobar" (::auth/token response))))
(session/write! manager "foobar" {:profile-id (:id profile)
:user-agent "user agent"
:created-at (ct/now)})
(let [response (handler (->DummyRequest {} {"auth-token" "foobar"}))]
(t/is (= :cookie (::auth/token-type response)))
(t/is (= "foobar" (::auth/token response)))
(t/is (= (:id profile) (::session/profile-id response)))
(t/is (= "foobar" (::session/id response))))))

View File

@@ -23,7 +23,7 @@
(smt/check!
(smt/for [context (->> sg/int
(sg/fmap (fn [_]
(#'rpc.doc/openapi-context (::rpc/methods th/*system*)))))]
(rpc.doc/prepare-openapi-context (::rpc/methods th/*system*)))))]
(try
(json/encode context)
true

View File

@@ -19,7 +19,6 @@
[app.http :as http]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :as files]
[app.setup.clock :as clock]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
@@ -143,112 +142,126 @@
(t/is (= 0 (count result))))))))
(t/deftest file-gc-with-fragments
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
(letfn [(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
(let [params {::th/type :update-file
::rpc/profile-id profile-id
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(:result out)))]
page-id (uuid/random)
shape-id (uuid/random)]
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
;; Preventive file-gc
(t/is (true? (th/run-task! :file-gc {:file-id (:id file) :revn (:revn file)})))
page-id (uuid/random)
shape-id (uuid/random)]
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Preventive file-gc
(t/is (true? (th/run-task! :file-gc {:file-id (:id file) :revn (:revn file)})))
;; Add page
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test"
:id page-id}])
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; Add page
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test"
:id page-id}])
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; Check the number of fragments before adding the page
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 5 (count rows)))
(t/is (= 3 (count (filterv :deleted-at rows)))))
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 5 (count rows)))
(t/is (= 3 (count (filterv :deleted-at rows)))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; Add shape to page that should add a new fragment
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id shape-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id shape-id
:name "image"
:frame-id uuid/zero
:parent-id uuid/zero
:type :rect})}])
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; Add shape to page that should add a new fragment
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id shape-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id shape-id
:name "image"
:frame-id uuid/zero
:parent-id uuid/zero
:type :rect})}])
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; Check the number of fragments
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 3 (count rows))))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; The file-gc should mark for remove unused fragments
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file)
:type "fragment"
:deleted-at nil})]
(t/is (= 2 (count rows))))
;; The objects-gc should remove unused fragments
(let [res (th/run-task! :objects-gc {})]
(t/is (= 3 (:processed res))))
;; Lets proceed to delete all changes
(th/db-delete! :file-change {:file-id (:id file)})
(th/db-delete! :file-data {:file-id (:id file) :type "snapshot"})
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file)
:type "fragment"
:deleted-at nil})]
(t/is (= 2 (count rows))))
(th/db-update! :file
{:has-media-trimmed false}
{:id (:id file)})
;; Lets proceed to delete all changes
(th/db-delete! :file-change {:file-id (:id file)})
(th/db-delete! :file-data {:file-id (:id file) :type "snapshot"})
;; The file-gc should remove fragments related to changes
;; snapshots previously deleted.
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(th/db-update! :file
{:has-media-trimmed false}
{:id (:id file)})
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
;; (pp/pprint rows)
(t/is (= 4 (count rows)))
(t/is (= 2 (count (remove :deleted-at rows)))))
;; The file-gc should remove fragments related to changes
;; snapshots previously deleted.
(t/is (true? (th/run-task! :file-gc {:file-id (:id file)})))
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
;; Check the number of fragments;
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
;; (pp/pprint rows)
(t/is (= 4 (count rows)))
(t/is (= 2 (count (remove :deleted-at rows)))))
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows))))))
(let [res (th/run-task! :objects-gc {})]
(t/is (= 2 (:processed res))))
(let [rows (th/db-query :file-data {:file-id (:id file) :type "fragment"})]
(t/is (= 2 (count rows)))))))
(t/deftest file-gc-with-thumbnails
(letfn [(add-file-media-object [& {:keys [profile-id file-id]}]
@@ -266,6 +279,20 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(:result out)))
(update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}]
(let [params {::th/type :update-file
::rpc/profile-id profile-id
:id file-id
:session-id (uuid/random)
:revn revn
:vern 0
:features cfeat/supported-features
:changes changes}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(:result out)))]
(let [storage (:app.storage/storage th/*system*)
@@ -1866,125 +1893,3 @@
(t/is (= (:id file-2) (:file-id (get rows 0))))
(t/is (nil? (:deleted-at (get rows 0)))))))
(t/deftest deleted-files-permanently-delete
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)]
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "foobar"
:is-shared false
:components-v2 true}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result)))))
(let [data {::th/type :delete-file
:id file-id
::rpc/profile-id (:id prof)}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; get deleted files
(let [data {::th/type :get-team-deleted-files
::rpc/profile-id (:id prof)
:team-id team-id}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[row1 :as result] (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:will-be-deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z"))))
(let [data {::th/type :permanently-delete-team-files
::rpc/profile-id (:id prof)
:team-id team-id
:ids #{file-id}}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:ids data) result)))
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (= (:deleted-at row) now)))))))
(t/deftest deleted-files-restore
(let [prof (th/create-profile* 1 {:is-active true})
team-id (:default-team-id prof)
proj-id (:default-project-id prof)
file-id (uuid/next)
now (ct/inst "2025-10-31T00:00:00Z")]
(binding [ct/*clock* (clock/fixed now)]
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "foobar"
:is-shared false
:components-v2 true}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:name data) (:name result)))
(t/is (= proj-id (:project-id result)))))
(let [data {::th/type :delete-file
:id file-id
::rpc/profile-id (:id prof)}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(t/is (nil? (:result out))))
;; get deleted files
(let [data {::th/type :get-team-deleted-files
::rpc/profile-id (:id prof)
:team-id team-id}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[row1 :as result] (:result out)]
(t/is (= 1 (count result)))
(t/is (= (:will-be-deleted-at row1) #penpot/inst "2025-11-07T00:00:00Z"))
(t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z"))
(t/is (= (:modified-at row1) #penpot/inst "2025-10-31T00:00:00Z"))))
(let [data {::th/type :restore-deleted-team-files
::rpc/profile-id (:id prof)
:team-id team-id
:ids #{file-id}}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (fn? result))
(let [events (th/consume-sse result)]
;; (pp/pprint events)
(t/is (= 2 (count events)))
(t/is (= :end (first (last events))))
(t/is (= (:ids data) (last (last events)))))))
(let [row (th/db-exec-one! ["select * from file where id = ?" file-id])]
(t/is (nil? (:deleted-at row)))))))

View File

@@ -104,8 +104,7 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 1 (count (remove :deleted-at result))))
(t/is (= 2 (count result)))))))
(t/is (= 1 (count result)))))))
(t/deftest permissions-checks-create-project
(let [profile1 (th/create-profile* 1)
@@ -208,8 +207,7 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 2 (count result)))
(t/is (= 1 (count (remove :deleted-at result))))))
(t/is (= 1 (count result)))))
;; run permanent deletion (should be noop)
(let [result (th/run-task! :objects-gc {})]

View File

@@ -1024,35 +1024,6 @@
:clj
(sort comp-fn items))))
(defn reorder
"Reorder a vector by moving one of their items from some position to some space between positions.
It clamps the position numbers to a valid range."
[v from-pos to-space-between-pos]
(let [max-space-pos (count v)
max-prop-pos (dec max-space-pos)
from-pos (max 0 (min max-prop-pos from-pos))
to-space-between-pos (max 0 (min max-space-pos to-space-between-pos))]
(if (= from-pos to-space-between-pos)
v
(let [elem (nth v from-pos)
without-elem (-> []
(into (subvec v 0 from-pos))
(into (subvec v (inc from-pos))))
insert-pos (if (< from-pos to-space-between-pos)
(dec to-space-between-pos)
to-space-between-pos)]
(-> []
(into (subvec without-elem 0 insert-pos))
(into [elem])
(into (subvec without-elem insert-pos)))))))
(defn invert-map
"Returns a map with keys and values swapped.
If the input map has duplicate values, later entries overwrite earlier ones."
[m]
(into {} (map (fn [[k v]] [v k]) m)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; String Functions

View File

@@ -517,7 +517,8 @@
(when verify?
(check-changes items))
(binding [*touched-changes* (volatile! #{})]
(binding [*touched-changes* (volatile! #{})
cts/*wasm-sync* true]
(let [result (reduce #(or (process-change %1 %2) %1) data items)
result (reduce process-touched-change result @*touched-changes*)]
;; Validate result shapes (only on the backend)

View File

@@ -638,7 +638,6 @@
(reduce add-undo-change-shape $ ids)))
(apply-changes-local)))))
;; FIXME: PERFORMANCE
(defn resize-parents
[changes ids]
(assert-page-id! changes)

View File

@@ -1357,6 +1357,38 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0004-clean-shadow-color"
[data _]
(let [decode-color (sm/decoder types.color/schema:color sm/json-transformer)
clean-shadow-color
(fn [color]
(let [ref-id (get color :id)
ref-file (get color :file-id)]
(-> (d/without-qualified color)
(select-keys [:opacity :color :gradient :image :ref-id :ref-file])
(cond-> ref-id
(assoc :ref-id ref-id))
(cond-> ref-file
(assoc :ref-file ref-file))
(decode-color))))
clean-shadow
(fn [shadow]
(update shadow :color clean-shadow-color))
update-object
(fn [object]
(d/update-when object :shadow #(mapv clean-shadow %)))
update-container
(fn [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0005-deprecate-image-type"
[data _]
(letfn [(update-object [object]
@@ -1381,27 +1413,17 @@
(defmethod migrate-data "0006-fix-old-texts-fills"
[data _]
(letfn [(fix-fills [node]
(let [;; In the old format refs were strings
sanitize-uuid
(fn [o]
(if (uuid? o)
o
(uuid/parse* o)))
fills
(if (and (not (seq (:fills node)))
(or (some? (:fill-color node))
(some? (:fill-opacity node))
(some? (:fill-color-gradient node))))
[(-> (select-keys node types.fills/fill-attrs)
(update :fill-color-ref-file sanitize-uuid)
(update :fill-color-ref-id sanitize-uuid)
(d/without-nils))]
(:fills node))]
(reduce dissoc
(assoc node :fills fills)
types.fills/fill-attrs)))
(let [fills (if (and (not (seq (:fills node)))
(or (some? (:fill-color node))
(some? (:fill-opacity node))
(some? (:fill-color-gradient node))))
[(d/without-nils (select-keys node [:fill-color :fill-opacity :fill-color-gradient
:fill-color-ref-id :fill-color-ref-file]))]
(:fills node))]
(-> node
(assoc :fills fills)
(dissoc :fill-color :fill-opacity :fill-color-gradient
:fill-color-ref-id :fill-color-ref-file))))
(update-object [object]
(if (cfh/text-shape? object)
@@ -1608,164 +1630,6 @@
;; as value; this migration fixes it.
(d/update-when data :components d/update-vals d/without-nils))
(defmethod migrate-data "0015-fix-text-attrs-blank-strings"
[data _]
;; After making text validation more restrictive (using ::sm/text
;; instead of :string), we need to fix text attributes that contain
;; empty or blank strings. These should be replaced with default
;; values from default-text-attrs.
(letfn [(blank-or-empty? [v]
(or (nil? v)
(and (string? v)
(or (str/empty? v)
(str/blank? v)))))
(get-default-value [attr]
(let [defaults types.text/default-text-attrs]
(case attr
;; direction in content maps to text-direction in defaults
:direction (:text-direction defaults)
;; For other attrs, get directly from defaults
(get defaults attr))))
(fix-text-attrs [node]
;; These are the attributes that were changed to ::sm/text in the schema
(let [text-attrs [:font-family :font-size :font-style :font-weight
:direction :text-decoration :text-transform]]
(reduce
(fn [node attr]
(if (and (contains? node attr)
(blank-or-empty? (get node attr)))
;; Replace blank/empty value with default
(if-let [default-val (get-default-value attr)]
(assoc node attr default-val)
;; If no default, remove the attribute
(dissoc node attr))
node))
node
text-attrs)))
(fix-position-data [position-data]
(mapv fix-text-attrs position-data))
(fix-text-content [content]
(types.text/transform-nodes types.text/is-content-node? fix-text-attrs content))
(update-shape [object]
(if (cfh/text-shape? object)
(-> object
(d/update-when :content fix-text-content)
(d/update-when :position-data fix-position-data))
object))
(update-container [container]
(d/update-when container :objects d/update-vals update-shape))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0015-clean-shadow-color"
[data _]
(let [decode-shadow-color
(sm/decoder ctss/schema:color sm/json-transformer)
clean-shadow-color
(fn [color]
(let [ref-id (get color :id)
ref-file (get color :file-id)]
(-> (d/without-qualified color)
(select-keys ctss/color-attrs)
(cond-> ref-id
(assoc :ref-id ref-id))
(cond-> ref-file
(assoc :ref-file ref-file))
(decode-shadow-color)
(d/without-nils))))
clean-shadow
(fn [shadow]
(update shadow :color clean-shadow-color))
clean-xform
(comp
(keep clean-shadow)
(filter ctss/valid-shadow?))
update-object
(fn [object]
(d/update-when object :shadow #(into [] clean-xform %)))
update-container
(fn [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
;; Copy fills from position-data to text nodes when all text nodes lack fills,
;; all position-data have fills, and the counts match
(defmethod migrate-data "0016-copy-fills-from-position-data-to-text-node"
[data _]
(letfn [(get-text-nodes [content]
;; Get all leaf text nodes from the content tree
(when content
(->> (types.text/node-seq types.text/is-text-node? content)
(seq))))
(update-content [content fills-map]
;; Transform the content tree to update text nodes with their corresponding fills
;; fills-map is a map from text node to its fills
(types.text/transform-nodes
types.text/is-text-node?
(fn [text-node]
(if-let [fills (get fills-map text-node)]
(assoc text-node :fills fills)
text-node))
content))
(update-object [object]
(if (cfh/text-shape? object)
(let [content (:content object)
position-data (:position-data object)
text-nodes (get-text-nodes content)]
;; Check if conditions are met:
;; 1. Has at least one text node
;; 2. All text nodes have no fills or empty fills
;; 3. Has at least one position-data entry
;; 4. All position-data have fills
;; 5. The number of text nodes matches the number of position-data
(if (and (seq text-nodes)
(seq position-data)
(= (count text-nodes) (count position-data))
(every? (fn [text-node]
(let [fills (:fills text-node)]
(or (nil? fills) (empty? fills))))
text-nodes)
(every? (fn [pd]
(let [fills (:fills pd)]
(and (some? fills) (seq fills))))
position-data))
;; Apply the migration: create a map from each text node to its corresponding fills
(let [fills-map (zipmap text-nodes (map :fills position-data))]
(update object :content #(update-content % fills-map)))
;; Don't modify if conditions aren't met
object))
;; Not a text shape, return as-is
object))
(update-container [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(def available-migrations
(into (d/ordered-set)
["legacy-2"
@@ -1825,6 +1689,7 @@
"0002-clean-shape-interactions"
"0003-fix-root-shape"
"0003-convert-path-content-v2"
"0004-clean-shadow-color"
"0005-deprecate-image-type"
"0006-fix-old-texts-fills"
"0008-fix-library-colors-v4"
@@ -1836,7 +1701,4 @@
"0013-fix-component-path"
"0013-clear-invalid-strokes-and-fills"
"0014-fix-tokens-lib-duplicate-ids"
"0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"]))
"0014-clear-components-nil-objects"]))

View File

@@ -44,7 +44,7 @@
(let [attr? (set attributes)]
(->> (remove (fn [[k v]]
(and (attr? k)
(= v (or (token-identifier token) token))))
(= v (token-identifier token))))
applied-tokens)
(into {}))))

View File

@@ -466,20 +466,19 @@
children (map #(ctst/get-shape page %) shapes)
prop-names (cfv/extract-properties-names (first children) (:data file))]
(doseq [child children]
(when child
(if (not (ctk/is-variant? child))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(do
(when (not= (:variant-id child) shape-id)
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(when (not= prop-names (cfv/extract-properties-names child (:data file)))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page))))))))
(if (not (ctk/is-variant? child))
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id child))
child file page)
(do
(when (not= (:variant-id child) shape-id)
(report-error :invalid-variant-id
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
child file page))
(when (not= prop-names (cfv/extract-properties-names child (:data file)))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page)))))))
(defn- check-variant
"Shape is a variant, so
@@ -628,8 +627,7 @@
main-component (if (:deleted component)
(dm/get-in component [:objects (:main-instance-id component)])
(ctst/get-shape component-page (:main-instance-id component)))]
(when (and main-component
(not (ctk/is-variant? main-component)))
(when-not (ctk/is-variant? main-component)
(report-error :not-a-variant
(str/ffmt "Shape % should be a variant" (:id main-component))
main-component file component-page))))

View File

@@ -10,23 +10,16 @@
[app.common.types.components-list :as ctcl]
[app.common.types.variant :as ctv]))
(defn find-variant-components
"Find a list of the components thet belongs to this variant-id"
([data variant-id]
(let [page-id (->> data
:components
vals
(filter #(= (:variant-id %) variant-id))
first
:main-instance-page)
objects (dm/get-in data [:pages-index page-id :objects])]
(find-variant-components data objects variant-id)))
([data objects variant-id]
;; We can't simply filter components, because we need to maintain the order
(->> (dm/get-in objects [variant-id :shapes])
(map #(dm/get-in objects [% :component-id]))
(map #(ctcl/get-component data % true))
reverse)))
[data objects variant-id]
;; We can't simply filter components, because we need to maintain the order
(->> (dm/get-in objects [variant-id :shapes])
(map #(dm/get-in objects [% :component-id]))
(map #(ctcl/get-component data % true))
reverse))
(defn extract-properties-names
[shape data]
@@ -35,6 +28,7 @@
:variant-properties
(map :name)))
(defn extract-properties-values
"Get a map of properties associated to their possible values"
[data objects variant-id]
@@ -56,6 +50,7 @@
(get :objects))]
(dm/get-in objects [variant-id :shapes]))))
(defn is-secondary-variant?
[component data]
(let [shapes (get-variant-mains component data)]

View File

@@ -123,7 +123,6 @@
:token-color
:token-typography-types
:token-typography-composite
:token-shadow
:transit-readable-response
:user-feedback
;; TODO: remove this flag.

View File

@@ -1642,8 +1642,7 @@
(pcb/apply-changes-local)))))
(defn- generate-update-tokens
[changes container dest-shape origin-shape touched omit-touched? valid-attrs]
;; valid-attrs is a set of attrs to consider on the update. If it is nil, it will consider all the attrs
[changes container dest-shape origin-shape touched omit-touched?]
(let [attrs (->> (seq (keys ctk/sync-attrs))
;; We don't update the flex-child attrs
(remove #(= :layout-grid-cells %)))
@@ -1651,8 +1650,8 @@
applied-tokens (reduce (fn [applied-tokens attr]
(let [attr-group (get ctk/sync-attrs attr)
token-attrs (cto/shape-attr->token-attrs attr)]
(if (and (or (not omit-touched?) (not (touched attr-group)))
(or (empty? valid-attrs) (contains? valid-attrs attr)))
(if (not (and (touched attr-group)
omit-touched?))
(into applied-tokens token-attrs)
applied-tokens)))
#{}
@@ -1809,7 +1808,7 @@
:always
(check-detached-main dest-shape origin-shape)
:always
(generate-update-tokens container dest-shape origin-shape touched omit-touched? nil))
(generate-update-tokens container dest-shape origin-shape touched omit-touched?))
(let [attr-group (get ctk/sync-attrs attr)
;; position-data is a special case because can be affected by
@@ -1992,12 +1991,6 @@
;; If the values are already equal, don't copy them
(= (get previous-shape attr) (get current-shape attr))
;; If the value is the same as the origin, don't copy it
(= (get previous-shape attr) (get origin-ref-shape attr))
;; If the attr is not touched, don't copy it
(not (touched attr-group))
;; If both variants (origin and destiny) don't have the same value
;; for that attribute, don't copy it.
;; Exceptions: :points :selrect and :content can be different
@@ -2013,7 +2006,10 @@
(not= (get origin-ref-shape attr) (get current-shape attr)))
;; The :content attr cant't be copied to elements of different type
(and (= attr :content) (not= (:type previous-shape) (:type current-shape))))
(and (= attr :content) (not= (:type previous-shape) (:type current-shape)))
;; If the attr is not touched, don't copy it
(not (touched attr-group)))
;; On texts, both text (the actual letters)
;; and attrs (bold, font, etc) are in the same attr :content.
@@ -2086,14 +2082,12 @@
(recur (next attrs)
roperations'
uoperations'))
(cond-> changes
(> (count roperations) 1)
(add-update-attr-changes current-shape container roperations uoperations)
(let [updated-attrs (into #{} (comp (filter #(= :set (:type %)))
(map :attr))
roperations)]
(cond-> changes
(> (count roperations) 1)
(-> (add-update-attr-changes current-shape container roperations uoperations)
(generate-update-tokens container current-shape previous-shape touched false updated-attrs))))))))
:always
(generate-update-tokens container current-shape previous-shape touched false))))))
(defn- propagate-attrs
"Helper that puts the origin attributes (attrs) into dest but only if
@@ -2804,7 +2798,7 @@
(defn generate-duplicate-changes
"Prepare objects to duplicate: generate new id, give them unique names,
move to the desired position, and recalculate parents and frames as needed."
[changes all-objects page ids delta libraries library-data file-id & {:keys [variant-props alt-duplication?]}]
[changes all-objects page ids delta libraries library-data file-id & {:keys [variant-props]}]
(let [shapes (map (d/getf all-objects) ids)
unames (volatile! (cfh/get-used-names (:objects page)))
update-unames! (fn [new-name] (vswap! unames conj new-name))
@@ -2814,22 +2808,10 @@
;; we calculate a new one because the components will have created new shapes.
ids-map (into {} (map #(vector % (uuid/next))) all-ids)
;; If there is an alt-duplication of a variant, change its parent to root
;; so the copy is made as a child of root
;; This is because inside a variant-container can't be a copy
shapes (map (fn [shape]
(if (and alt-duplication? (ctk/is-variant? shape))
(assoc shape :parent-id uuid/zero :frame-id nil)
shape))
shapes)
changes (-> changes
(pcb/with-page page)
(pcb/with-objects all-objects)
(pcb/with-library-data library-data))
changes
(->> shapes
(reduce #(generate-duplicate-shape-change %1

View File

@@ -28,7 +28,11 @@
(pcb/update-component
changes (:id component)
(fn [component]
(d/update-in-when component [:variant-properties pos] #(assoc % :name new-name)))
(d/update-in-when component [:variant-properties pos]
(fn [property]
(-> property
(assoc :name new-name)
(with-meta nil)))))
{:apply-changes-local-library? true}))
changes
related-components)))
@@ -38,21 +42,18 @@
[changes variant-id pos]
(let [data (pcb/get-library-data changes)
objects (pcb/get-objects changes)
related-components (cfv/find-variant-components data objects variant-id)
props (-> related-components first :variant-properties)]
(if (and (seq props) (<= 0 pos) (< pos (count props)))
(reduce (fn [changes component]
(let [props (:variant-properties component)
props (d/remove-at-index props pos)
main-id (:main-instance-id component)
name (ctv/properties-to-name props)]
(-> changes
(pcb/update-component (:id component) #(assoc % :variant-properties props)
{:apply-changes-local-library? true})
(pcb/update-shapes [main-id] #(assoc % :variant-name name)))))
changes
related-components)
changes)))
related-components (cfv/find-variant-components data objects variant-id)]
(reduce (fn [changes component]
(let [props (:variant-properties component)
props (d/remove-at-index props pos)
main-id (:main-instance-id component)
name (ctv/properties-to-name props)]
(-> changes
(pcb/update-component (:id component) #(assoc % :variant-properties props)
{:apply-changes-local-library? true})
(pcb/update-shapes [main-id] #(assoc % :variant-name name)))))
changes
related-components)))
(defn generate-update-property-value
@@ -87,7 +88,7 @@
related-components (cfv/find-variant-components data objects variant-id)]
(reduce (fn [changes component]
(let [props (:variant-properties component)
props (d/reorder props from-pos to-space-between-pos)
props (ctv/reorder-by-moving-to-position props from-pos to-space-between-pos)
main-id (:main-instance-id component)
name (ctv/properties-to-name props)]
(-> changes

View File

@@ -67,6 +67,7 @@
[[] {}]
shapes))))
(defn- keep-swapped-item
"As part of the keep-touched process on a switch, given a child on the original
copy that was swapped (orig-swapped-child), and its related shape on the new copy
@@ -87,6 +88,7 @@
current-parent (get objects (:parent-id related-shape-in-new))
pos (d/index-of (:shapes current-parent) (:id related-shape-in-new))]
(-> (pcb/concat-changes before-changes changes)
;; Move the previous shape to the new parent
@@ -120,29 +122,6 @@
(subvec (vec ancestors) 1 (dec num-ancestors)))]
(some ctk/get-swap-slot ancestors)))
(defn- find-shape-ref-child-of
"Get the shape referenced by the shape-ref of the near main of the shape,
recursively repeated until find a shape-ref with parent-id as ancestor.
It will return the shape or nil if it doesn't found any"
[container libraries shape parent-id]
(let [ref-shape (ctf/find-ref-shape nil container libraries shape
:with-context? true)
ref-shape-container (when ref-shape (:container (meta ref-shape)))
ref-shape-parents-set (when ref-shape
(->> (cfh/get-parents-with-self (:objects ref-shape-container) (:id ref-shape))
(into #{} d/xf:map-id)))]
(if (or (nil? ref-shape) (contains? ref-shape-parents-set parent-id))
ref-shape
(find-shape-ref-child-of ref-shape-container libraries ref-shape parent-id))))
(defn- add-touched-from-ref-chain
"Adds to the :touched attr of a shape the content of
the :touched of all its chain of ref shapes"
[container libraries shape]
(let [new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape nil)]
(assoc shape :touched new-touched)))
(defn generate-keep-touched
"This is used as part of the switch process, when you switch from
@@ -162,10 +141,7 @@
;; Ignore children of swapped items, because
;; they will be moved without change when
;; managing their swapped ancestor
orig-touched (->> original-shapes
;; Add to each shape also the touched of its ref chain
(map #(add-touched-from-ref-chain container libraries %))
(filter (comp seq :touched))
orig-touched (->> (filter (comp seq :touched) original-shapes)
(remove
#(child-of-swapped? %
page-objects
@@ -182,19 +158,20 @@
;; The original-shape is in a copy. For the relation rules, we need the referenced
;; shape on the main component
orig-base-ref-shape (ctf/find-remote-shape container libraries original-shape {:with-context? true})
orig-ref-objects (:objects (:container (meta orig-base-ref-shape)))
orig-ref-shape (ctf/find-ref-shape nil container libraries original-shape {:with-context? true})
orig-ref-objects (:objects (:container (meta orig-ref-shape)))
;; Adds a :shape-path attribute to the children of the orig-ref-shape,
;; that contains the type of its ancestors and its name
o-ref-shapes-wp (add-unique-path
(reverse (cfh/get-children-with-self orig-ref-objects (:id orig-base-ref-shape)))
(reverse (cfh/get-children-with-self orig-ref-objects (:id orig-ref-shape)))
orig-ref-objects
(:id orig-base-ref-shape))
(:id orig-ref-shape))
;; Creates a map to quickly find a child of the orig-ref-shape by its shape-path
o-ref-shapes-p-map (into {} (map (juxt :id :shape-path)) o-ref-shapes-wp)
;; Process each touched children of the original-shape
[changes parents-of-swapped]
(reduce
@@ -205,7 +182,8 @@
;; orig-child-touched is in a copy. Get the referenced shape on the main component
;; If there is a swap slot, we will get the referenced shape in another way
orig-ref-shape (when-not swap-slot
(find-shape-ref-child-of container libraries orig-child-touched (:id orig-base-ref-shape)))
;; TODO Maybe just get it from o-ref-shapes-wp
(ctf/find-ref-shape nil container libraries orig-child-touched))
orig-ref-id (if swap-slot
;; If there is a swap slot, find the referenced shape id
@@ -215,11 +193,9 @@
;; Get the shape path of the referenced main
shape-path (get o-ref-shapes-p-map orig-ref-id)
;; Get its related shape in the children of new-shape: the one that
;; has the same shape-path
related-shape-in-new (get new-shapes-map shape-path)
parents-of-swapped (if related-shape-in-new
(conj parent-of-swapped (:parent-id related-shape-in-new))
parent-of-swapped)

View File

@@ -36,7 +36,7 @@
(defn type
[s]
(m/type s default-options))
(m/-type s))
(defn properties
[s]
@@ -46,10 +46,6 @@
[s]
(m/type-properties s))
(defn children
[s]
(m/children s default-options))
(defn schema
[s]
(if (schema? s)
@@ -131,19 +127,9 @@
(defn keys
"Given a map schema, return all keys as set"
[schema']
(let [schema' (m/schema schema' default-options)]
(case (m/type schema')
:map
(->> (entries schema')
(into #{} xf:map-key))
:merge
(->> (m/children schema')
(mapcat m/entries)
(into #{} xf:map-key))
(throw (ex-info "not supported schema type" {:type (m/type schema')})))))
[schema]
(->> (entries schema)
(into #{} xf:map-key)))
(defn update-properties
[s f & args]

View File

@@ -14,14 +14,13 @@
(defn add-variant
[file variant-label component1-label root1-label component2-label root2-label
& {:keys [variant1-params variant2-params]
:or {variant1-params {} variant2-params {}}}]
& {:keys []}]
(let [file (ths/add-sample-shape file variant-label :type :frame :is-variant-container true)
variant-id (thi/id variant-label)]
(-> file
(ths/add-sample-shape root2-label (assoc variant2-params :type :frame :parent-label variant-label :variant-id variant-id :variant-name "Value2"))
(ths/add-sample-shape root1-label (assoc variant1-params :type :frame :parent-label variant-label :variant-id variant-id :variant-name "Value1"))
(ths/add-sample-shape root2-label :type :frame :parent-label variant-label :variant-id variant-id :variant-name "Value2")
(ths/add-sample-shape root1-label :type :frame :parent-label variant-label :variant-id variant-id :variant-name "Value1")
(thc/make-component component1-label root1-label)
(thc/update-component component1-label {:variant-id variant-id :variant-properties [{:name "Property 1" :value "Value1"}]})
(thc/make-component component2-label root2-label)
@@ -43,8 +42,7 @@
(defn add-variant-with-child
[file variant-label component1-label root1-label component2-label root2-label child1-label child2-label
& {:keys [child1-params child2-params]
:or {child1-params {} child2-params {}}}]
& {:keys [child1-params child2-params]}]
(let [file (ths/add-sample-shape file variant-label :type :frame :is-variant-container true)
variant-id (thi/id variant-label)]
(-> file

View File

@@ -286,7 +286,7 @@
(fn [touched]
(into #{} (remove #(str/starts-with? (name %) "swap-slot-") touched)))))
(defn get-deleted-component-root
(defn get-component-root
[component]
(if (some? (:main-instance-id component))
(get-in component [:objects (:main-instance-id component)])

View File

@@ -32,7 +32,6 @@
[app.common.types.typographies-list :as ctyl]
[app.common.types.typography :as cty]
[app.common.uuid :as uuid]
[clojure.set :as set]
[cuerdas.core :as str]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -277,7 +276,7 @@
(-> file-data
(get-component-page component)
(ctn/get-shape (:main-instance-id component)))
(ctk/get-deleted-component-root component)))
(ctk/get-component-root component)))
(defn get-component-shape
"Retrieve one shape in the component by id. If with-context? is true, add the
@@ -356,7 +355,7 @@
(defn find-remote-shape
"Recursively go back by the :shape-ref of the shape until find the correct shape of the original component"
[container libraries shape & {:keys [with-context?] :or {with-context? false}}]
[container libraries shape]
(let [top-instance (ctn/get-component-shape (:objects container) shape)
component-file (get-in libraries [(:component-file top-instance) :data])
component (ctkl/get-component component-file (:component-id top-instance) true)
@@ -376,12 +375,8 @@
(if (nil? remote-shape)
nil
(if (nil? (:shape-ref remote-shape))
(cond-> remote-shape
(and remote-shape with-context?)
(with-meta {:file {:id (:id file-data)
:data file-data}
:container component-container}))
(find-remote-shape component-container libraries remote-shape :with-context? with-context?)))))
remote-shape
(find-remote-shape component-container libraries remote-shape)))))
(defn direct-copy?
"Check if the shape is in a direct copy of the component (i.e. the shape-ref points to shapes inside
@@ -906,7 +901,7 @@
(println))
(when (seq (:objects component))
(let [root (ctk/get-deleted-component-root component)]
(let [root (ctk/get-component-root component)]
(dump-shape (:id root)
1
(:objects component)
@@ -1120,29 +1115,3 @@
(defn set-base-font-size
[file-data base-font-size]
(assoc-in file-data [:options :base-font-size] base-font-size))
;; Ref Chains
(defn get-ref-chain-until-target-ref
"Returns a vector with the shape ref chain until target-ref, including itself"
[container libraries shape target-ref]
(loop [chain [shape]
current shape]
(if (= current target-ref)
chain
(if-let [ref (find-ref-shape nil container libraries current :with-context? true)]
(recur (conj chain ref) ref)
chain))))
(defn get-touched-from-ref-chain-until-target-ref
"Returns a set with the :touched of all the items on the shape
ref chain until target-ref, including itself"
[container libraries shape target-ref]
(let [chain (get-ref-chain-until-target-ref container libraries shape target-ref)
more-touched (->> chain
(map :touched)
(remove nil?)
(apply set/union)
(remove ctk/swap-slot?)
set)]
(set/union (or (:touched shape) #{}) more-touched)))

View File

@@ -301,17 +301,11 @@
IHeapWritable
(-get-byte-size [_]
;; Include the 4-byte header with the fill count
(+ 4 (* size FILL-U8-SIZE)))
(- (.-byteLength dbuffer) 4))
(-write-to [_ heap offset]
(let [buffer' (.-buffer ^js/DataView dbuffer)
;; Calculate byte size: 4 bytes header + (size * FILL-U8-SIZE)
byte-size (+ 4 (* size FILL-U8-SIZE))
;; Create Uint32Array with exact size needed (convert bytes to u32 elements)
u32-array (js/Uint32Array. buffer' 0 (/ byte-size 4))]
;; Copy from offset 0 to include the header with fill count
(.set heap u32-array offset)))
(let [buffer' (.-buffer ^js/DataView dbuffer)]
(.set heap (js/Uint32Array. buffer' 4) offset)))
IBinaryFills
(-get-image-ids [_]

View File

@@ -498,10 +498,10 @@
[:map
[:x schema:safe-number]
[:y schema:safe-number]
[:c1x {:optional true} schema:safe-number]
[:c1y {:optional true} schema:safe-number]
[:c2x {:optional true} schema:safe-number]
[:c2y {:optional true} schema:safe-number]]]])
[:c1x schema:safe-number]
[:c1y schema:safe-number]
[:c2x schema:safe-number]
[:c2y schema:safe-number]]]])
(def ^:private schema:segment
[:multi {:title "PathSegment"

View File

@@ -36,7 +36,8 @@
[app.common.uuid :as uuid]
[clojure.set :as set]))
(defonce ^:dynamic *shape-changes* nil)
(defonce ^:dynamic *wasm-sync* false)
(defonce wasm-enabled? false)
(defonce wasm-create-shape (constantly nil))

View File

@@ -11,14 +11,6 @@
(def styles #{:drop-shadow :inner-shadow})
(def schema:color
[:merge {:title "ShadowColor"}
ctc/schema:color-attrs
ctc/schema:plain-color])
(def color-attrs
(sm/keys schema:color))
(def schema:shadow
[:map {:title "Shadow"}
[:id [:maybe ::sm/uuid]]
@@ -28,7 +20,7 @@
[:blur ::sm/safe-number]
[:spread ::sm/safe-number]
[:hidden :boolean]
[:color schema:color]])
[:color ctc/schema:color]])
(def check-shadow
(sm/check-fn schema:shadow))

View File

@@ -34,13 +34,13 @@
[:fills {:optional true}
[:maybe
[:vector {:gen/max 2} schema:fill]]]
[:font-family {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text]
[:font-weight {:optional true} ::sm/text]
[:direction {:optional true} ::sm/text]
[:text-decoration {:optional true} ::sm/text]
[:text-transform {:optional true} ::sm/text]
[:font-family {:optional true} :string]
[:font-size {:optional true} :string]
[:font-style {:optional true} :string]
[:font-weight {:optional true} :string]
[:direction {:optional true} :string]
[:text-decoration {:optional true} :string]
[:text-transform {:optional true} :string]
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]
[:children
@@ -51,13 +51,13 @@
[:fills {:optional true}
[:maybe
[:vector {:gen/max 2} schema:fill]]]
[:font-family {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text]
[:font-weight {:optional true} ::sm/text]
[:direction {:optional true} ::sm/text]
[:text-decoration {:optional true} ::sm/text]
[:text-transform {:optional true} ::sm/text]
[:font-family {:optional true} :string]
[:font-size {:optional true} :string]
[:font-style {:optional true} :string]
[:font-weight {:optional true} :string]
[:direction {:optional true} :string]
[:text-decoration {:optional true} :string]
[:text-transform {:optional true} :string]
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]])
@@ -72,11 +72,11 @@
[:width ::sm/safe-number]
[:height ::sm/safe-number]
[:fills [:vector {:gen/max 2} schema:fill]]
[:font-family {:optional true} ::sm/text]
[:font-size {:optional true} ::sm/text]
[:font-style {:optional true} ::sm/text]
[:font-weight {:optional true} ::sm/text]
[:font-family {:optional true} :string]
[:font-size {:optional true} :string]
[:font-style {:optional true} :string]
[:font-weight {:optional true} :string]
[:rtl {:optional true} :boolean]
[:text {:optional true} :string]
[:text-decoration {:optional true} ::sm/text]
[:text-transform {:optional true} ::sm/text]]])
[:text-decoration {:optional true} :string]
[:text-transform {:optional true} :string]]])

View File

@@ -249,16 +249,12 @@
(defn equal-attrs?
"Given a text structure, and a map of attrs, check that all the internal attrs in
paragraphs and sentences have the same attrs"
([item attrs]
;; Ignore the root attrs of the content. We only want to check paragraphs and sentences
(equal-attrs? item attrs true))
([item attrs ignore?]
(let [item-attrs (dissoc item :text :type :key :children)]
(and
(or ignore?
(empty? item-attrs)
(= attrs (dissoc item :text :type :key :children)))
(every? #(equal-attrs? % attrs false) (:children item))))))
[item attrs]
(let [item-attrs (dissoc item :text :type :key :children)]
(and
(or (empty? item-attrs)
(= attrs (dissoc item :text :type :key :children)))
(every? #(equal-attrs? % attrs) (:children item)))))
(defn get-first-paragraph-text-attrs
"Given a content text structure, extract it's first paragraph

View File

@@ -54,7 +54,6 @@
(def token-type->dtcg-token-type
{:boolean "boolean"
:border-radius "borderRadius"
:shadow "shadow"
:color "color"
:dimensions "dimension"
:font-family "fontFamilies"
@@ -78,8 +77,7 @@
;; Allow these properties to be imported with singular key names for backwards compability
(assoc "fontWeight" :font-weight
"fontSize" :font-size
"fontFamily" :font-family
"boxShadow" :shadow)))
"fontFamily" :font-family)))
(def composite-token-type->dtcg-token-type
"Custom set of conversion keys for composite typography token with `:line-height` available.
@@ -117,12 +115,6 @@
(def border-radius-keys (schema-keys schema:border-radius))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} token-name-ref]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:stroke-width
[:map
[:stroke-width {:optional true} token-name-ref]])
@@ -279,7 +271,6 @@
(def all-keys (set/union color-keys
border-radius-keys
shadow-keys
stroke-width-keys
sizing-keys
opacity-keys
@@ -298,7 +289,6 @@
[:merge {:title "AppliedTokens"}
schema:tokens
schema:border-radius
schema:shadow
schema:sizing
schema:spacing
schema:rotation
@@ -344,7 +334,6 @@
(font-weight-keys shape-attr) #{shape-attr :typography}
(border-radius-keys shape-attr) #{shape-attr}
(shadow-keys shape-attr) #{shape-attr}
(sizing-keys shape-attr) #{shape-attr}
(opacity-keys shape-attr) #{shape-attr}
(spacing-keys shape-attr) #{shape-attr}
@@ -372,7 +361,6 @@
rotation-keys
sizing-keys
opacity-keys
shadow-keys
position-attributes))
(def rect-attributes
@@ -456,30 +444,6 @@
spacing-margin-keys)]
(unapply-token-id shape layout-item-attrs)))
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
:height #{:sizing :dimensions}
:max-width #{:sizing :dimensions}
:max-height #{:sizing :dimensions}
:x #{:spacing :dimensions}
:y #{:spacing :dimensions}
:rotation #{:number :rotation}
:border-radius #{:border-radius :dimensions}
:row-gap #{:spacing :dimensions}
:column-gap #{:spacing :dimensions}
:horizontal-padding #{:spacing :dimensions}
:vertical-padding #{:spacing :dimensions}
:sided-paddings #{:spacing :dimensions}
:horizontal-margin #{:spacing :dimensions}
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}
:stroke-color #{:color}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TYPOGRAPHY
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -550,11 +514,26 @@
[token-value]
(string? token-value))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SHADOW
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn shadow-composite-token-reference?
"Predicate if a shadow composite token is a reference value - a string pointing to another reference token."
[token-value]
(string? token-value))
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
:height #{:sizing :dimensions}
:max-width #{:sizing :dimensions}
:max-height #{:sizing :dimensions}
:x #{:spacing :dimensions}
:y #{:spacing :dimensions}
:rotation #{:number :rotation}
:border-radius #{:border-radius :dimensions}
:row-gap #{:spacing :dimensions}
:column-gap #{:spacing :dimensions}
:horizontal-padding #{:spacing :dimensions}
:vertical-padding #{:spacing :dimensions}
:sided-paddings #{:spacing :dimensions}
:horizontal-margin #{:spacing :dimensions}
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}
:stroke-color #{:color}})

View File

@@ -1552,46 +1552,6 @@ Will return a value that matches this schema:
;; Reference value
value))
(defn- convert-dtcg-shadow-composite
"Convert shadow token value from DTCG format to internal format."
[value]
(let [process-shadow (fn [shadow]
(if (map? shadow)
(let [legacy-shadow-type (get "type" shadow)]
(-> shadow
(set/rename-keys {"x" :offsetX
"offsetX" :offsetX
"y" :offsetY
"offsetY" :offsetY
"blur" :blur
"spread" :spread
"color" :color
"inset" :inset})
(update :inset #(cond
(boolean? %) %
(= "true" %) true
(= "false" %) false
(= legacy-shadow-type "innerShadow") true
:else false))
(select-keys [:offsetX :offsetY :blur :spread :color :inset])))
shadow))]
(cond
;; Reference value - keep as string
(string? value)
value
;; Array of shadows - process each
(sequential? value)
(mapv process-shadow value)
;; Single shadow object - wrap in vector
(map? value)
[(process-shadow value)]
;; Fallback - keep as is
:else
value)))
(defn- flatten-nested-tokens-json
"Convert a tokens tree in the decoded json fragment into a flat map,
being the keys the token paths after joining the keys with '.'."
@@ -1614,7 +1574,6 @@ Will return a value that matches this schema:
(case token-type
:font-family (convert-dtcg-font-family token-value)
:typography (convert-dtcg-typography-composite token-value)
:shadow (convert-dtcg-shadow-composite token-value)
token-value))
:description (get v "$description")))
;; Discard unknown type tokens
@@ -1780,32 +1739,11 @@ Will return a value that matches this schema:
{} value)
value))
(defn- shadow-token->dtcg-token
"Convert shadow token value from internal format to DTCG format."
[value]
(if (sequential? value)
(mapv (fn [shadow]
(if (map? shadow)
(-> shadow
(set/rename-keys {:offsetX "offsetX"
:offsetY "offsetY"
:blur "blur"
:spread "spread"
:color "color"
:inset "inset"})
(select-keys ["offsetX" "offsetY" "blur" "spread" "color" "inset"]))
shadow))
value)
value))
(defn- token->dtcg-token [token]
(cond-> {"$value" (cond-> (:value token)
;; Transform typography token values
(= :typography (:type token))
typography-token->dtcg-token
;; Transform shadow token values
(= :shadow (:type token))
shadow-token->dtcg-token)
typography-token->dtcg-token)
"$type" (cto/token-type->dtcg-token-type (:type token))}
(:description token) (assoc "$description" (:description token))))
@@ -2053,19 +1991,13 @@ Will return a value that matches this schema:
#?(:clj
(defn- migrate-to-v1-4
"Migrate the TokensLib data structure internals to v1.4 version; it
"Migrate the TokensLib data structure internals to v1.2 version; it
expects input from v1.3 version"
[params]
(let [migrate-set-node
(fn recurse [node]
(cond
(token-set-legacy? node)
(if (token-set-legacy? node)
(make-token-set node)
(token-set? node)
node
:else
(d/update-vals node recurse)))]
(update params :sets d/update-vals migrate-set-node))))

View File

@@ -311,16 +311,26 @@
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
(defn find-boolean-pair
"Given a vector, return the map from 'bool-values' that contains both as keys.
Returns nil if none match."
[v]
(let [bool-values [{"on" true "off" false}
{"yes" true "no" false}
{"true" true "false" false}]]
(when (= (count v) 2)
(some (fn [b]
(when (and (contains? b (first v))
(contains? b (last v)))
b))
bool-values))))
(defn reorder-by-moving-to-position
"Reorder a vector by moving one of their items from some position to some space between positions.
It clamps the position numbers to a valid range."
[props from-pos to-space-between-pos]
(let [max-space-pos (count props)
max-prop-pos (dec max-space-pos)
from-pos (max 0 (min max-prop-pos from-pos))
to-space-between-pos (max 0 (min max-space-pos to-space-between-pos))]
(if (= from-pos to-space-between-pos)
props
(let [elem (nth props from-pos)
without-elem (-> []
(into (subvec props 0 from-pos))
(into (subvec props (inc from-pos))))
insert-pos (if (< from-pos to-space-between-pos)
(dec to-space-between-pos)
to-space-between-pos)]
(-> []
(into (subvec without-elem 0 insert-pos))
(into [elem])
(into (subvec without-elem insert-pos)))))))

View File

@@ -8,7 +8,6 @@
(:refer-clojure :exclude [uri?])
(:require
[app.common.data.macros :as dm]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[lambdaisland.uri.normalize :as un])
#?(:clj
@@ -59,14 +58,6 @@
(map (fn [[k v]] [(key-fn k) (value-fn v)]))))
(u/map->query-string))))
(defn ensure-path-slash
[u]
(update (uri u) :path
(fn [path]
(if (str/ends-with? path "/")
path
(str path "/")))))
#?(:clj
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
(.write writer "#")

View File

@@ -102,14 +102,3 @@
(t/is (= (d/insert-at-index [:a :b :c :d] 1 [:a])
[:a :b :c :d])))
(t/deftest reorder
(let [v ["a" "b" "c" "d"]]
(t/is (= (d/reorder v 0 2) ["b" "a" "c" "d"]))
(t/is (= (d/reorder v 0 3) ["b" "c" "a" "d"]))
(t/is (= (d/reorder v 0 4) ["b" "c" "d" "a"]))
(t/is (= (d/reorder v 3 0) ["d" "a" "b" "c"]))
(t/is (= (d/reorder v 3 2) ["a" "b" "d" "c"]))
(t/is (= (d/reorder v 0 5) ["b" "c" "d" "a"]))
(t/is (= (d/reorder v 3 -1) ["d" "a" "b" "c"]))
(t/is (= (d/reorder v 5 -1) ["d" "a" "b" "c"]))
(t/is (= (d/reorder v -1 5) ["b" "c" "d" "a"]))))

View File

@@ -18,29 +18,6 @@
(t/use-fixtures :each thi/test-fixture)
(t/deftest test-basic-switch
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant
:v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 5}
:variant2-params {:width 15}})
(thc/instantiate-component :c01
:copy01))
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy01' (ths/get-shape file' :copy02)]
(thf/dump-file file :keys [:width])
;; The copy had width 5 before the switch
(t/is (= (:width copy01) 5))
;; The rect has width 15 after the switch
(t/is (= (:width copy01') 15))))
(t/deftest test-simple-switch
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -69,40 +46,6 @@
(t/is (= (:width rect02') 15))))
(t/deftest test-basic-switch-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant
:v01 :c01 :m01 :c02 :m02
{:variant1-params {:width 5}
:variant2-params {:width 5}})
(thc/instantiate-component :c01
:copy01))
copy01 (ths/get-shape file :copy01)
;; Change width of copy
page (thf/current-page file)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy01)}
(fn [shape]
(assoc shape :width 25))
(:objects page)
{})
file (thf/apply-changes file changes)
copy01 (ths/get-shape file :copy01)
;; ==== Action
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy01' (ths/get-shape file' :copy02)]
(thf/dump-file file :keys [:width])
;; The copy had width 25 before the switch
(t/is (= (:width copy01) 25))
;; The override is keept: The copy still has width 25 after the switch
(t/is (= (:width copy01') 25))))
(t/deftest test-switch-with-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -182,10 +125,12 @@
;; The rect has width 15 after the switch
(t/is (= (:width rect02') 15))))
(def font-size-path-paragraph [:content :children 0 :children 0 :font-size])
(def font-size-path-0 [:content :children 0 :children 0 :children 0 :font-size])
(def font-size-path-1 [:content :children 0 :children 0 :children 1 :font-size])
(def text-path-0 [:content :children 0 :children 0 :children 0 :text])
(def text-path-1 [:content :children 0 :children 0 :children 1 :text])
(def text-lines-path [:content :children 0 :children 0 :children])
@@ -243,8 +188,6 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
@@ -266,8 +209,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
@@ -293,8 +234,6 @@
;; Before the switch:
;; * font size 14
;; * text "hello world"
(t/is (= (get-in copy-clean-t font-size-path-0) "14"))
(t/is (= (get-in copy-clean-t text-path-0) "hello world"))
@@ -309,8 +248,6 @@
;; Before the switch:
;; * font size 25
;; * text "hello world"
(t/is (= (get-in copy-font-size-t font-size-path-0) "25"))
(t/is (= (get-in copy-font-size-t text-path-0) "hello world"))
@@ -369,8 +306,6 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
@@ -392,8 +327,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
@@ -419,8 +352,6 @@
;; Before the switch:
;; * font size 14
;; * text "hello world"
(t/is (= (get-in copy-clean-t font-size-path-0) "14"))
(t/is (= (get-in copy-clean-t text-path-0) "hello world"))
@@ -435,8 +366,6 @@
;; Before the switch:
;; * font size 25
;; * text "hello world"
(t/is (= (get-in copy-font-size-t font-size-path-0) "25"))
(t/is (= (get-in copy-font-size-t text-path-0) "hello world"))
@@ -472,6 +401,7 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "50"))
(t/is (= (get-in copy-both-t' text-path-0) "text overriden"))))
(t/deftest test-switch-with-different-text-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -493,8 +423,6 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
@@ -516,8 +444,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
@@ -543,8 +469,6 @@
;; Before the switch:
;; * font size 14
;; * text "hello world"
(t/is (= (get-in copy-clean-t font-size-path-0) "14"))
(t/is (= (get-in copy-clean-t text-path-0) "hello world"))
@@ -559,8 +483,6 @@
;; Before the switch:
;; * font size 25
;; * text "hello world"
(t/is (= (get-in copy-font-size-t font-size-path-0) "25"))
(t/is (= (get-in copy-font-size-t text-path-0) "hello world"))
@@ -596,6 +518,7 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "25"))
(t/is (= (get-in copy-both-t' text-path-0) "bye"))))
(t/deftest test-switch-with-different-text-and-prop-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -619,8 +542,6 @@
;; The copy clean has no overrides
copy-clean (ths/get-shape file :copy-clean)
copy-clean-t (ths/get-shape file :copy-clean-t)
@@ -642,8 +563,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true})
(tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true})
@@ -669,8 +588,6 @@
;; Before the switch:
;; * font size 14
;; * text "hello world"
(t/is (= (get-in copy-clean-t font-size-path-0) "14"))
(t/is (= (get-in copy-clean-t text-path-0) "hello world"))
@@ -685,8 +602,6 @@
;; Before the switch:
;; * font size 25
;; * text "hello world"
(t/is (= (get-in copy-font-size-t font-size-path-0) "25"))
(t/is (= (get-in copy-font-size-t text-path-0) "hello world"))
@@ -722,6 +637,7 @@
(t/is (= (get-in copy-both-t' font-size-path-0) "50"))
(t/is (= (get-in copy-both-t' text-path-0) "bye"))))
(t/deftest test-switch-with-identical-structure-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -741,8 +657,6 @@
;; Duplicate a text line in copy-structure-clean
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
@@ -764,8 +678,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
@@ -851,6 +763,7 @@
(t/is (= (get-in copy-structure-mixed-t' font-size-path-1) "40"))
(t/is (= (get-in copy-structure-mixed-t' text-path-1) "new line 2"))))
(t/deftest test-switch-with-different-prop-structure-text-override
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -871,8 +784,6 @@
;; Duplicate a text line in copy-structure-clean
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
@@ -894,8 +805,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
@@ -997,8 +906,6 @@
;; Duplicate a text line in copy-structure-clean
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
@@ -1020,8 +927,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
@@ -1066,8 +971,6 @@
;; Second line:
;; * font size 25
;; * text "new line 2"
(t/is (= (get-in copy-structure-unif-t font-size-path-0) "25"))
(t/is (= (get-in copy-structure-unif-t text-path-0) "new line 1"))
(t/is (= (get-in copy-structure-unif-t font-size-path-1) "25"))
@@ -1089,8 +992,6 @@
;; Before the switch, second line:
;; * font size 40
;; * text "new line 2"
(t/is (= (get-in copy-structure-mixed-t font-size-path-0) "35"))
(t/is (= (get-in copy-structure-mixed-t text-path-0) "new line 1"))
(t/is (= (get-in copy-structure-mixed-t font-size-path-1) "40"))
@@ -1124,8 +1025,6 @@
;; Duplicate a text line in copy-structure-clean
file (change-structure file :copy-structure-clean-t)
copy-structure-clean (ths/get-shape file :copy-structure-clean)
copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t)
@@ -1147,8 +1046,6 @@
;; ==== Action: Switch all the copies
file' (-> file
(tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true})
(tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true})
@@ -1193,8 +1090,6 @@
;; Second line:
;; * font size 25
;; * text "new line 2"
(t/is (= (get-in copy-structure-unif-t font-size-path-0) "25"))
(t/is (= (get-in copy-structure-unif-t text-path-0) "new line 1"))
(t/is (= (get-in copy-structure-unif-t font-size-path-1) "25"))
@@ -1216,8 +1111,6 @@
;; Before the switch, second line:
;; * font size 40
;; * text "new line 2"
(t/is (= (get-in copy-structure-mixed-t font-size-path-0) "35"))
(t/is (= (get-in copy-structure-mixed-t text-path-0) "new line 1"))
(t/is (= (get-in copy-structure-mixed-t font-size-path-1) "40"))
@@ -1231,6 +1124,7 @@
(t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye"))
(t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1)))))
(t/deftest test-switch-variant-for-other-with-same-nested-component
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -1250,8 +1144,6 @@
;; On :copy-cp01, change the width of the rect
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{copy-cp01-rect-id}
(fn [shape]
@@ -1274,6 +1166,8 @@
;; The width of copy-cp02-rect' is 25 (change is preserved)
(t/is (= (:width copy-cp02-rect') 25))))
(t/deftest test-switch-variant-that-has-swaped-copy
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -1299,6 +1193,7 @@
;; Switch :c01 for :c02
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy02' (ths/get-shape file' :copy02)
copy-cp02' (ths/get-shape file' :copy-cp02)]
(thf/dump-file file')
@@ -1312,6 +1207,7 @@
;;copy-02' had copy-cp02' as child
(t/is (= (-> copy02' :shapes first) (:id copy-cp02')))))
(t/deftest test-switch-variant-that-has-swaped-copy-with-changed-attr
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
@@ -1348,6 +1244,7 @@
;; Switch :c01 for :c02
file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true})
copy02' (ths/get-shape file' :copy02)
copy-cp02' (ths/get-shape file' :copy-cp02)
copy-cp02-rect' (ths/get-shape-by-id file' (-> copy-cp02' :shapes first))]
@@ -1365,58 +1262,3 @@
(t/is (= (-> copy02' :shapes first) (:id copy-cp02')))
;; The width of copy-cp02-rect' is 25 (change is preserved)
(t/is (= (:width copy-cp02-rect') 25))))
(t/deftest test-switch-variant-without-touched-but-touched-parent
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(thv/add-variant-with-child
:v01 :c01 :m01 :c02 :m02 :r01 :r02
{:child1-params {:width 5}
:child2-params {:width 5}})
(tho/add-simple-component :external01 :external01-root :external01-child)
(thc/instantiate-component :c01
:c01-in-root
:children-labels [:r01-in-c01-in-root]
:parent-label :external01-root))
;; Make a change on r01-in-c01-in-root so it is touched
page (thf/current-page file)
r01-in-c01-in-root (ths/get-shape file :r01-in-c01-in-root)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id r01-in-c01-in-root)}
(fn [shape]
(assoc shape :width 25))
(:objects page)
{})
file (thf/apply-changes file changes)
;; Instantiate the component :external01
file (thc/instantiate-component file
:external01
:external-copy01
:children-labels [:external-copy01-rect :c01-in-copy])
page (thf/current-page file)
c01-in-copy (ths/get-shape file :c01-in-copy)
rect01 (get-in page [:objects (-> c01-in-copy :shapes first)])
;; ==== Action
file' (tho/swap-component file c01-in-copy :c02 {:new-shape-label :c02-in-copy :keep-touched? true})
page' (thf/current-page file')
c02-in-copy' (ths/get-shape file' :c02-in-copy)
rect02' (get-in page' [:objects (-> c02-in-copy' :shapes first)])]
(thf/dump-file file :keys [:width :touched])
;; The rect had width 25 before the switch
(t/is (= (:width rect01) 25))
;; The rect still has width 25 after the switch
(t/is (= (:width rect02') 25))))

View File

@@ -45,7 +45,6 @@
[common-tests.types.path-data-test]
[common-tests.types.shape-decode-encode-test]
[common-tests.types.shape-interactions-test]
[common-tests.types.token-test]
[common-tests.types.tokens-lib-test]
[common-tests.uuid-test]))
@@ -99,5 +98,4 @@
'common-tests.types.shape-decode-encode-test
'common-tests.types.shape-interactions-test
'common-tests.types.tokens-lib-test
'common-tests.types.token-test
'common-tests.uuid-test))

View File

@@ -1,60 +0,0 @@
{
"test": {
"shadow-single": {
"$value": {
"x": "0",
"y": "2px",
"blur": "4px",
"spread": "0",
"color": "#000"
},
"$type": "boxShadow"
},
"shadow-multiple": {
"$value": [
{
"x": "0",
"y": "2px",
"blur": "4px",
"spread": "0",
"color": "#000",
"inset": true
},
{
"x": "0",
"y": "8px",
"blur": "16px",
"spread": "0",
"color": "#000",
"inset": "true"
}
],
"$type": "boxShadow"
},
"shadow-ref": {
"$value": "{shadow-single}",
"$type": "boxShadow"
},
"shadow-with-type": {
"$value": {
"x": "0",
"y": "4px",
"blur": "8px",
"spread": "0",
"color": "rgba(0,0,0,0.2)",
"type": "innerShadow"
},
"$type": "boxShadow"
},
"shadow-with-description": {
"$value": {
"x": "1px",
"y": "1px",
"blur": "3px",
"color": "gray"
},
"$type": "boxShadow",
"$description": "A simple shadow token"
}
}
}

View File

@@ -116,27 +116,6 @@
(t/is (= sample-content
(vec pdata)))))
;; Test the specific case where cuve-to commands comes without the
;; optional attrs
(t/deftest path-data-plain-to-binary-2
(let [plain-content
[{:command :move-to :params {:x 480.0 :y 839.0}}
{:command :line-to :params {:x 439.0 :y 802.0}}
{:command :curve-to :params {:x 264.0 :y 634.0}}
{:command :curve-to :params {:x 154.0 :y 508.0}}]
binary-content
(path/content plain-content)]
#?(:clj
(t/is (= "M480.0,839.0L439.0,802.0C264.0,634.0,264.0,634.0,264.0,634.0C154.0,508.0,154.0,508.0,154.0,508.0"
(str binary-content)))
:cljs
(t/is (= "M480,839L439,802C264,634,264,634,264,634C154,508,154,508,154,508"
(str binary-content))))))
(t/deftest path-data-from-binary
(let [barray #?(:clj (byte-array sample-bytes)
:cljs (js/Int8Array.from sample-bytes))

View File

@@ -1,27 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.types.token-test
(:require
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/deftest test-valid-token-name-schema
;; Allow regular namespace token names
(t/is (true? (sm/validate cto/token-name-ref "Foo")))
(t/is (true? (sm/validate cto/token-name-ref "foo")))
(t/is (true? (sm/validate cto/token-name-ref "FOO")))
(t/is (true? (sm/validate cto/token-name-ref "Foo.Bar.Baz")))
;; Disallow trailing tokens
(t/is (false? (sm/validate cto/token-name-ref "Foo.Bar.Baz....")))
;; Disallow multiple separator dots
(t/is (false? (sm/validate cto/token-name-ref "Foo..Bar.Baz")))
;; Disallow any special characters
(t/is (false? (sm/validate cto/token-name-ref "Hey Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey😈Foo.Bar")))
(t/is (false? (sm/validate cto/token-name-ref "Hey%Foo.Bar"))))

View File

@@ -1362,7 +1362,9 @@
{:name "button.primary.background"
:type :color
:value "{accent.default}"
:description ""}))))))
:description ""})))
(t/testing "invalid tokens got discarded"
(t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default")))))))
#?(:clj
(t/deftest parse-multi-set-dtcg-json
@@ -1390,7 +1392,9 @@
{:name "button.primary.background"
:type :color
:value "{accent.default}"
:description ""}))))))
:description ""})))
(t/testing "invalid tokens got discarded"
(t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default")))))))
#?(:clj
(t/deftest parse-multi-set-dtcg-json-default-team
@@ -1889,130 +1893,3 @@
(t/is (some? imported-single))
(t/is (= (:type imported-single) (:type original-single)))
(t/is (= (:value imported-single) (:value original-single))))))))
#?(:clj
(t/deftest parse-shadow-tokens
(let [json (-> (slurp "test/common_tests/types/data/tokens-shadow-example.json")
(json/decode {:key-fn identity}))
lib (ctob/parse-decoded-json json "shadow-test")]
(t/testing "single shadow token"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-single")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= [{:offsetX "0", :offsetY "2px", :blur "4px", :spread "0", :color "#000", :inset false}]
(:value token)))))
(t/testing "multiple shadow token"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-multiple")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= [{:offsetX "0", :offsetY "2px", :blur "4px", :spread "0", :color "#000", :inset true}
{:offsetX "0", :offsetY "8px", :blur "16px", :spread "0", :color "#000", :inset true}]
(:value token)))))
(t/testing "shadow token with reference"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-ref")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= "{shadow-single}" (:value token)))))
(t/testing "shadow token with type"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= [{:offsetX "0", :offsetY "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}]
(:value token)))))
(t/testing "shadow token with description"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-description")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= "A simple shadow token" (:description token))))))))
#?(:clj
(t/deftest export-shadow-tokens
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set
(ctob/make-token-set
:name "shadow-set"
:tokens {"shadow.single"
(ctob/make-token
{:name "shadow.single"
:type :shadow
:value [{:offsetX "0" :offsetY "2px" :blur "4px" :spread "0" :color "#0000001A"}]
:description "A single shadow"})
"shadow.multiple"
(ctob/make-token
{:name "shadow.multiple"
:type :shadow
:value [{:offsetX "0" :offsetY "2px" :blur "4px" :spread "0" :color "#0000001A"}
{:offsetX "0" :offsetY "8px" :blur "16px" :spread "0" :color "#0000001A"}]})
"shadow.ref"
(ctob/make-token
{:name "shadow.ref"
:type :shadow
:value "{shadow.single}"})
"shadow.empty"
(ctob/make-token
{:name "shadow.empty"
:type :shadow
:value {}})})))
result (ctob/export-dtcg-json tokens-lib)
shadow-set (get result "shadow-set")]
(t/testing "single shadow token export"
(let [single-token (get-in shadow-set ["shadow" "single"])]
(t/is (= "shadow" (get single-token "$type")))
(t/is (= [{"offsetX" "0" "offsetY" "2px" "blur" "4px" "spread" "0" "color" "#0000001A"}] (get single-token "$value")))
(t/is (= "A single shadow" (get single-token "$description")))))
(t/testing "multiple shadow token export"
(let [multiple-token (get-in shadow-set ["shadow" "multiple"])]
(t/is (= "shadow" (get multiple-token "$type")))
(t/is (= [{"offsetX" "0" "offsetY" "2px" "blur" "4px" "spread" "0" "color" "#0000001A"}
{"offsetX" "0" "offsetY" "8px" "blur" "16px" "spread" "0" "color" "#0000001A"}]
(get multiple-token "$value")))))
(t/testing "reference shadow token export"
(let [ref-token (get-in shadow-set ["shadow" "ref"])]
(t/is (= "shadow" (get ref-token "$type")))
(t/is (= "{shadow.single}" (get ref-token "$value")))))
(t/testing "empty shadow token export"
(let [empty-token (get-in shadow-set ["shadow" "empty"])]
(t/is (= "shadow" (get empty-token "$type")))
(t/is (= {} (get empty-token "$value"))))))))
#?(:clj
(t/deftest shadow-token-round-trip
(let [original-lib (-> (ctob/make-tokens-lib)
(ctob/add-set
(ctob/make-token-set
:name "test-set"
:tokens {"shadow.test"
(ctob/make-token
{:name "shadow.test"
:type :shadow
:value [{:offsetX "1" :offsetY "1" :blur "1" :spread "1" :color "red" :inset true}]
:description "Round trip test"})
"shadow.ref"
(ctob/make-token
{:name "shadow.ref"
:type :shadow
:value "{shadow.test}"})})))
exported (ctob/export-dtcg-json original-lib)
imported-lib (ctob/parse-decoded-json exported "")]
(t/testing "round trip preserves shadow tokens"
(let [original-token (ctob/get-token-by-name original-lib "test-set" "shadow.test")
imported-token (ctob/get-token-by-name imported-lib "test-set" "shadow.test")]
(t/is (some? imported-token))
(t/is (= (:type original-token) (:type imported-token)))
(t/is (= (:value original-token) (:value imported-token)))
(t/is (= (:description original-token) (:description imported-token))))
(let [original-ref (ctob/get-token-by-name original-lib "test-set" "shadow.ref")
imported-ref (ctob/get-token-by-name imported-lib "test-set" "shadow.ref")]
(t/is (some? imported-ref))
(t/is (= (:type original-ref) (:type imported-ref)))
(t/is (= (:value imported-ref) (:value original-ref))))))))

View File

@@ -161,11 +161,46 @@
(t/is (= (ctv/update-number-in-repeated-prop-names props) numbered-props)))))
(t/deftest find-boolean-pair
(t/testing "find-boolean-pair"
(t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil))
(t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false}))
(t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))
(t/deftest reorder-by-moving-to-position
(let [props [{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}]]
(t/testing "reorder-by-moving-to-position"
(t/is (= (ctv/reorder-by-moving-to-position props 0 2) [{:name "color" :value "blue"}
{:name "border" :value "no"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 0 3) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "border" :value "no"}
{:name "background" :value "none"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 0 4) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}
{:name "border" :value "no"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 3 0) [{:name "background" :value "none"}
{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 3 2) [{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "background" :value "none"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 0 5) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}
{:name "border" :value "no"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 3 -1) [{:name "background" :value "none"}
{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props 5 -1) [{:name "background" :value "none"}
{:name "border" :value "no"}
{:name "color" :value "blue"}
{:name "shadow" :value "yes"}]))
(t/is (= (ctv/reorder-by-moving-to-position props -1 5) [{:name "color" :value "blue"}
{:name "shadow" :value "yes"}
{:name "background" :value "none"}
{:name "border" :value "no"}])))))

View File

@@ -73,7 +73,7 @@ RUN set -eux; \
FROM base AS setup-node
ENV NODE_VERSION=v22.21.1 \
ENV NODE_VERSION=v22.19.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \
@@ -113,12 +113,12 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
ESUM='b60eb9d54c97ba4159547834a98cc5d016281dd2b3e60e7475cba4911324bcb4'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jdk25.0.0-linux_aarch64.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
ESUM='164d901e5a240b8c18516f5ab55bc11fc9689ab6e829045aea8467356dcdb340'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jdk25.0.0-linux_x64.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \
@@ -149,24 +149,18 @@ FROM base AS setup-rust
ENV PATH=/opt/cargo/bin:$PATH \
RUSTUP_HOME=/opt/rustup \
CARGO_HOME=/opt/cargo \
RUSTUP_VERSION=1.28.2 \
RUST_VERSION=1.91.0 \
RUSTUP_VERSION=1.27.1 \
RUST_VERSION=1.85.0 \
EMSCRIPTEN_VERSION=4.0.6
WORKDIR /opt
RUN set -eux; \
# Same steps as in Rust official Docker image https://github.com/rust-lang/docker-rust/blob/9f287282d513a84cb7c7f38f197838f15d37b6a9/1.81.0/bookworm/Dockerfile
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
'amd64') \
rustArch='x86_64-unknown-linux-gnu'; \
rustupSha256='20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c'; \
;; \
'arm64') \
rustArch='aarch64-unknown-linux-gnu'; \
rustupSha256='e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c'; \
;; \
dpkgArch="$(dpkg --print-architecture)"; \
case "${dpkgArch##*-}" in \
amd64) rustArch='x86_64-unknown-linux-gnu'; rustupSha256='6aeece6993e902708983b209d04c0d1dbb14ebb405ddb87def578d41f920f56d' ;; \
arm64) rustArch='aarch64-unknown-linux-gnu'; rustupSha256='1cffbf51e63e634c746f741de50649bbbcbd9dbe1de363c9ecef64e278dba2b2' ;; \
*) echo >&2 "unsupported architecture: ${dpkgArch}"; exit 1 ;; \
esac; \
wget "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${rustArch}/rustup-init"; \

View File

@@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
LC_ALL='C.UTF-8' \
JAVA_HOME="/opt/jdk" \
DEBIAN_FRONTEND=noninteractive \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v22.19.0 \
TZ=Etc/UTC
RUN set -ex; \
@@ -46,12 +46,12 @@ RUN set -eux; \
ARCH="$(dpkg --print-architecture)"; \
case "${ARCH}" in \
aarch64|arm64) \
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
ESUM='6f8725d186d05c627176db9c46c732a6ef3ba41d9e9b3775c4727fc8ac642bb2'; \
BINARY_URL='https://github.com/adoptium/temurin24-binaries/releases/download/jdk-24.0.2%2B12/OpenJDK24U-jdk_aarch64_linux_hotspot_24.0.2_12.tar.gz'; \
;; \
amd64|x86_64) \
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
ESUM='aea1cc55e51cf651c85f2f00ad021603fe269c4bb6493fa97a321ad770c9b096'; \
BINARY_URL='https://github.com/adoptium/temurin24-binaries/releases/download/jdk-24.0.2%2B12/OpenJDK24U-jdk_x64_linux_hotspot_24.0.2_12.tar.gz'; \
;; \
*) \
echo "Unsupported arch: ${ARCH}"; \

View File

@@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \
NODE_VERSION=v22.19.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH

View File

@@ -1,20 +0,0 @@
FROM nginxinc/nginx-unprivileged:1.29.1
LABEL maintainer="Penpot <docker@penpot.app>"
USER root
RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot;
ARG BUNDLE_PATH="./bundle-storybook/"
COPY $BUNDLE_PATH /var/www/
COPY ./files/nginx.storybook.conf /etc/nginx/conf.d/default.conf
RUN chown -R 1001:0 /var/cache/nginx; \
chmod -R g+w /var/cache/nginx; \
chown -R 1001:0 /etc/nginx; \
chmod -R g+w /etc/nginx; \
chown -R 1001:0 /var/www; \
chmod -R g+w /var/www;
USER penpot:penpot

View File

@@ -247,11 +247,6 @@ services:
networks:
- penpot
environment:
# You can increase the max memory size if you have sufficient resources,
# although this should not be necessary.
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the
## port 1080 for read all emails the penpot platform has sent. Should be only used as a
## temporal solution while no real SMTP provider is configured.

View File

@@ -1,27 +0,0 @@
server {
listen 8080 default_server;
server_name _;
charset utf-8;
etag off;
gzip on;
gzip_static on;
gzip_types text/plain text/css application/javascript application/json application/vnd.api+json application/xml application/x-javascript text/xml image/svg+xml;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_vary on;
error_log /dev/stderr;
access_log /dev/stdout;
root /var/www;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,27 +0,0 @@
server {
listen 8080 default_server;
server_name _;
charset utf-8;
etag off;
gzip on;
gzip_static on;
gzip_types text/plain text/css application/javascript application/json application/vnd.api+json application/xml application/x-javascript text/xml image/svg+xml;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_vary on;
error_log /dev/stderr;
access_log /dev/stdout;
root /var/www;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -81,9 +81,6 @@ module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("js");
// Redirects (for Cloudflare)
eleventyConfig.addPassthroughCopy({"_redirects": "_redirects" });
/* Markdown Overrides */
let markdownLibrary = markdownIt({
html: true,

View File

@@ -4,7 +4,7 @@ templateClass: tmpl-user-guide
---
{%- macro show_children(item) -%}
{%- for child in item | children | sorted('data.order') %}
{%- for child in item | children | sorted('data.title') %}
{%- if loop.first -%}<ul>{%- endif -%}
<li>
<a href="{{ child.url }}">{{ child.data.title }}</a>

View File

@@ -1,239 +0,0 @@
/user-guide/introduction/ /user-guide/first-steps/
/user-guide/introduction/quickstart/ /user-guide/first-steps/cloud-selfhost/
/user-guide/introduction/shortcuts/ /user-guide/first-steps/shortcuts/
/user-guide/introduction/shortcuts/#workspace-section /user-guide/first-steps/shortcuts/#workspace-section
/user-guide/introduction/shortcuts/#alignment /user-guide/first-steps/shortcuts/#alignment
/user-guide/introduction/shortcuts/#edit /user-guide/first-steps/shortcuts/#edit
/user-guide/introduction/shortcuts/#main-menu /user-guide/first-steps/shortcuts/#main-menu
/user-guide/introduction/shortcuts/#modify-layers /user-guide/first-steps/shortcuts/#modify-layers
/user-guide/introduction/shortcuts/#goto-screens-workspace /user-guide/first-steps/shortcuts/#goto-screens-workspace
/user-guide/introduction/shortcuts/#panels /user-guide/first-steps/shortcuts/#panels
/user-guide/introduction/shortcuts/#path-editor /user-guide/first-steps/shortcuts/#path-editor
/user-guide/introduction/shortcuts/#shapes /user-guide/first-steps/shortcuts/#shapes
/user-guide/introduction/shortcuts/#tools /user-guide/first-steps/shortcuts/#tools
/user-guide/introduction/shortcuts/#zoom-workspace /user-guide/first-steps/shortcuts/#zoom-workspace
/user-guide/introduction/shortcuts/#text /user-guide/first-steps/shortcuts/#text
/user-guide/introduction/shortcuts/#dashboard-section /user-guide/first-steps/shortcuts/#dashboard-section
/user-guide/introduction/shortcuts/#generic-dashboard /user-guide/first-steps/shortcuts/#generic-dashboard
/user-guide/introduction/shortcuts/#navigation-dashboard /user-guide/first-steps/shortcuts/#navigation-dashboard
/user-guide/introduction/shortcuts/#viewer-section /user-guide/first-steps/shortcuts/#viewer-section
/user-guide/introduction/shortcuts/#generic-viewer /user-guide/first-steps/shortcuts/#generic-viewer
/user-guide/introduction/shortcuts/#navigation-viewer /user-guide/first-steps/shortcuts/#navigation-viewer
/user-guide/introduction/shortcuts/#zoom-viewer /user-guide/first-steps/shortcuts/#zoom-viewer
/user-guide/introduction/info/ /user-guide/first-steps/info/
/user-guide/introduction/info/#dev-diaries /user-guide/first-steps/info/
/user-guide/introduction/info/#video-tutorials /user-guide/first-steps/info/
/user-guide/introduction/info/#faqs /user-guide/first-steps/info/
/user-guide/the-interface/ /user-guide/first-steps/the-interface/
/user-guide/the-interface/#interface-workspace /user-guide/first-steps/the-interface/#interface-workspace
/user-guide/the-interface/#interface-viewmode /user-guide/first-steps/the-interface/#interface-viewmode
/user-guide/the-interface/#interface-dashboard /user-guide/first-steps/the-interface/#interface-dashboard
/user-guide/the-interface/#your-account /user-guide/account-teams/your-account/
/user-guide/the-interface/#interface-ui-theme /user-guide/account-teams/your-account/#interface-ui-theme
/user-guide/workspace-basics/ /user-guide/designing/workspace-basics/
/user-guide/workspace-basics/#viewport /user-guide/designing/workspace-basics/#viewport
/user-guide/workspace-basics/#workspace-menu /user-guide/designing/workspace-basics/#workspace-menu
/user-guide/workspace-basics/#zoom /user-guide/designing/workspace-basics/#zoom
/user-guide/workspace-basics/#dynamic-alignment /user-guide/designing/workspace-basics/#dynamic-alignment
/user-guide/workspace-basics/#rulers /user-guide/designing/workspace-basics/#rulers
/user-guide/workspace-basics/#ruler-guides /user-guide/designing/workspace-basics/#ruler-guides
/user-guide/workspace-basics/#guides /user-guide/designing/workspace-basics/#guides
/user-guide/workspace-basics/#add-guides /user-guide/designing/workspace-basics/#add-guides
/user-guide/workspace-basics/#hide-remove-guides /user-guide/designing/workspace-basics/#hide-remove-guides
/user-guide/workspace-basics/#square-guides /user-guide/designing/workspace-basics/#square-guides
/user-guide/workspace-basics/#row-guides /user-guide/designing/workspace-basics/#row-guides
/user-guide/workspace-basics/#column-guides /user-guide/designing/workspace-basics/#column-guides
/user-guide/workspace-basics/#guides-defaults /user-guide/designing/workspace-basics/#guides-defaults
/user-guide/workspace-basics/#guides-visibility /user-guide/designing/workspace-basics/#guides-visibility
/user-guide/workspace-basics/#guides-snap /user-guide/designing/workspace-basics/#guides-snap
/user-guide/workspace-basics/#snap-to-pixel /user-guide/designing/workspace-basics/#snap-to-pixel
/user-guide/workspace-basics/#nudge-amount /user-guide/designing/workspace-basics/#nudge-amount
/user-guide/workspace-basics/#shortcuts-panel /user-guide/designing/workspace-basics/#shortcuts-panel
/user-guide/workspace-basics/#history /user-guide/designing/workspace-basics/#history
/user-guide/workspace-basics/#comments /user-guide/designing/workspace-basics/#comments
/user-guide/layer-basics/ /user-guide/designing/layers/
/user-guide/layer-basics/#pages /user-guide/designing/workspace-basics/#layer-basics
/user-guide/layer-basics/#layers-panel /user-guide/layer-basics/#layers-panel
/user-guide/layer-basics/#hide-lock /user-guide/designing/layers/#hide-lock
/user-guide/layer-basics/#creating-layers /user-guide/designing/layers/#creating-layers
/user-guide/layer-basics/#duplicating-layers /user-guide/designing/layers/#duplicating-layers
/user-guide/layer-basics/#delete-layers /user-guide/designing/layers/#delete-layers
/user-guide/layer-basics/#select-layers /user-guide/designing/layers/#select-layers
/user-guide/layer-basics/#group-layers /user-guide/designing/layers/#group-layers
/user-guide/layer-basics/#mask-layers /user-guide/designing/layers/#mask-layers
/user-guide/layer-basics/#move-layers /user-guide/designing/layers/#move-layers
/user-guide/layer-basics/#resize-layers /user-guide/designing/layers/#resize-layers
/user-guide/layer-basics/#rotate-layers /user-guide/designing/layers/#rotate-layers
/user-guide/layer-basics/#flip-layers /user-guide/designing/layers/#flip-layers
/user-guide/layer-basics/#scale-elements /user-guide/designing/layers/#scale-elements
/user-guide/layer-basics/#aling-distribute-layers /user-guide/designing/layers/#aling-distribute-layers
/user-guide/layer-basics/#layers-search /user-guide/designing/workspace-basics/#layer-basics
/user-guide/layer-basics/#collapse-groups /user-guide/designing/workspace-basics/#layer-basics
/user-guide/layer-basics/#boolean-operators /user-guide/designing/layers/#boolean-operators
/user-guide/layer-basics/#constraints /user-guide/designing/layers/#constraints
/user-guide/layer-basics/#focus-mode /user-guide/designing/workspace-basics/#focus-mode
/user-guide/layer-basics/#rtl-support /user-guide/designing/text-typo/#rtl-support
/user-guide/objects/ /user-guide/designing/layers/
/user-guide/objects/#Boards /user-guide/designing/layers/#Boards
/user-guide/objects/#rectangles-ellipses /user-guide/designing/layers/#rectangles-ellipses
/user-guide/objects/#text /user-guide/designing/layers/#text
/user-guide/objects/#curves /user-guide/designing/layers/#curves
/user-guide/objects/#paths /user-guide/designing/layers/#paths
/user-guide/objects/#images /user-guide/designing/layers/#images
/user-guide/styling/ /user-guide/designing/layers/#styling-layers
/user-guide/styling/#fill /user-guide/designing/color-stroke/#fill
/user-guide/styling/#color-picker /user-guide/designing/color-stroke/#color-picker
/user-guide/styling/#color-picker-gradients /user-guide/designing/color-stroke/#color-picker-gradients
/user-guide/styling/#color-palette /user-guide/designing/color-stroke/#color-palette
/user-guide/styling/#selected-colors /user-guide/designing/color-stroke/#selected-colors
/user-guide/styling/#strokes /user-guide/designing/color-stroke/#strokes
/user-guide/styling/#stroke-caps /user-guide/designing/color-stroke/#stroke-caps
/user-guide/styling/#radius /user-guide/designing/layers/#radius
/user-guide/styling/#shadow /user-guide/designing/layers/#shadow
/user-guide/styling/#blur /user-guide/designing/layers/#blur
/user-guide/styling/#blend /user-guide/designing/layers/#blend
/user-guide/styling/#copy-paste-properties /user-guide/designing/layers/#copy-paste-properties
/user-guide/exporting/ /user-guide/export-import/exporting-layers/
/user-guide/exporting/#export-howto /user-guide/export-import/exporting-layers/#export-howto
/user-guide/exporting/#export-options /user-guide/export-import/exporting-layers/#export-options
/user-guide/exporting/#export-multiple-elements /user-guide/export-import/exporting-layers/#export-multiple-elements
/user-guide/exporting/#export-artboards-pdf /user-guide/export-import/exporting-layers/#export-artboards-pdf
/user-guide/exporting/#export-technical /user-guide/export-import/exporting-layers/#export-technical
/user-guide/flexible-layouts/ /user-guide/designing/flexible-layouts/
/user-guide/flexible-layouts/#layouts-flex /user-guide/designing/flexible-layouts/#layouts-flex
/user-guide/flexible-layouts/#layouts-flex-css /user-guide/designing/flexible-layouts/#layouts-flex
/user-guide/flexible-layouts/#layouts-flex-add /user-guide/designing/flexible-layouts/#layouts-flex-add
/user-guide/flexible-layouts/#layouts-flex-arrange-reorder /user-guide/designing/flexible-layouts/#layouts-flex-arrange-reorder
/user-guide/flexible-layouts/#layouts-flex-properties /user-guide/designing/flexible-layouts/#layouts-flex-properties
/user-guide/flexible-layouts/#layouts-flex-elements /user-guide/designing/flexible-layouts/#layouts-flex-elements
/user-guide/flexible-layouts/#layouts-flex-spacing /user-guide/designing/flexible-layouts/#layouts-flex-spacing
/user-guide/flexible-layouts/#layouts-flex-code /user-guide/designing/flexible-layouts/#layouts-flex-code
/user-guide/flexible-layouts/#layouts-flex-examples /user-guide/designing/flexible-layouts/#layouts-flex-examples
/user-guide/flexible-layouts/#layouts-grid /user-guide/designing/flexible-layouts/#layouts-grid
/user-guide/flexible-layouts/#layouts-flex-css /user-guide/designing/flexible-layouts/#layouts-grid
/user-guide/flexible-layouts/#layouts-grid-add /user-guide/designing/flexible-layouts/#layouts-grid-add
/user-guide/flexible-layouts/#layouts-grid-terminology /user-guide/designing/flexible-layouts/#layouts-grid-terminology
/user-guide/flexible-layouts/#layouts-grid-properties /user-guide/designing/flexible-layouts/#layouts-grid-properties
/user-guide/flexible-layouts/#layouts-grid-elements /user-guide/designing/flexible-layouts/#layouts-grid-elements
/user-guide/flexible-layouts/#layouts-grid-colsrows /user-guide/designing/flexible-layouts/#layouts-grid-colsrows
/user-guide/flexible-layouts/#layouts-grid-units /user-guide/designing/flexible-layouts/#layouts-grid-units
/user-guide/flexible-layouts/#layouts-grid-areas /user-guide/designing/flexible-layouts/#layouts-grid-areas
/user-guide/flexible-layouts/#layouts-grid-code /user-guide/designing/flexible-layouts/#layouts-grid-code
/user-guide/libraries/ /user-guide/design-systems/assets/
/user-guide/libraries/#assets /user-guide/design-systems/assets/
/user-guide/libraries/#asset-types /user-guide/design-systems/assets/#asset-types
/user-guide/libraries/#add-assets-to-library /user-guide/design-systems/assets/#add-assets-to-library
/user-guide/libraries/#edit-assets /user-guide/design-systems/assets/#edit-assets
/user-guide/libraries/#use-assets /user-guide/design-systems/assets/#use-assets
/user-guide/libraries/#organize-assets /user-guide/design-systems/assets/#organize-assets
/user-guide/libraries/#libraries /user-guide/design-systems/libraries/
/user-guide/libraries/#file-libraries /user-guide/design-systems/libraries/#file-libraries
/user-guide/libraries/#shared-libraries /user-guide/design-systems/libraries/#shared-libraries
/user-guide/design-tokens/ /user-guide/design-systems/design-tokens/
/user-guide/design-tokens/#design-tokens-why /user-guide/design-systems/design-tokens/
/user-guide/design-tokens/#design-tokens-format /user-guide/design-systems/design-tokens/
/user-guide/design-tokens/#design-tokens-use /user-guide/design-systems/design-tokens/#design-tokens-use-create
/user-guide/design-tokens/#design-tokens-use-create /user-guide/design-systems/design-tokens/#design-tokens-use-create
/user-guide/design-tokens/#design-tokens-aliases /user-guide/design-systems/design-tokens/#design-tokens-aliases
/user-guide/design-tokens/#design-tokens-equations /user-guide/design-systems/design-tokens/#design-tokens-equations
/user-guide/design-tokens/#design-tokens-edit /user-guide/design-systems/design-tokens/#design-tokens-edit
/user-guide/design-tokens/#design-tokens-duplicate /user-guide/design-systems/design-tokens/#design-tokens-duplicate
/user-guide/design-tokens/#design-tokens-delete /user-guide/design-systems/design-tokens/#design-tokens-delete
/user-guide/design-tokens/#design-tokens-available /user-guide/design-systems/design-tokens/#design-tokens-available
/user-guide/design-tokens/#design-tokens-radius /user-guide/design-systems/design-tokens/#design-tokens-radius
/user-guide/design-tokens/#design-tokens-color /user-guide/design-systems/design-tokens/#design-tokens-color
/user-guide/design-tokens/#design-tokens-dimensions /user-guide/design-systems/design-tokens/#design-tokens-dimensions
/user-guide/design-tokens/#design-tokens-opacity /user-guide/design-systems/design-tokens/#design-tokens-opacity
/user-guide/design-tokens/#design-tokens-rotation /user-guide/design-systems/design-tokens/#design-tokens-rotation
/user-guide/design-tokens/#design-tokens-sizing /user-guide/design-systems/design-tokens/#design-tokens-sizing
/user-guide/design-tokens/#design-tokens-spacing /user-guide/design-systems/design-tokens/#design-tokens-spacing
/user-guide/design-tokens/#design-tokens-stroke-width /user-guide/design-systems/design-tokens/#design-tokens-stroke-width
/user-guide/design-tokens/#design-tokens-number /user-guide/design-systems/design-tokens/#design-tokens-number
/user-guide/design-tokens/#design-tokens-typography /user-guide/design-systems/design-tokens/#design-tokens-typography
/user-guide/design-tokens/#design-tokens-sets /user-guide/design-systems/design-tokens/#design-tokens-sets
/user-guide/design-tokens/#design-tokens-sets-create /user-guide/design-systems/design-tokens/#design-tokens-sets
/user-guide/design-tokens/#design-tokens-sets-edit /user-guide/design-systems/design-tokens/#design-tokens-sets
/user-guide/design-tokens/#design-tokens-groups /user-guide/design-systems/design-tokens/#design-tokens-sets
/user-guide/design-tokens/#design-tokens-themes /user-guide/design-systems/design-tokens/#design-tokens-themes
/user-guide/design-tokens/#design-tokens-themes-create /user-guide/design-systems/design-tokens/#design-tokens-themes
/user-guide/design-tokens/#design-tokens-themes-edit /user-guide/design-systems/design-tokens/#design-tokens-themes
/user-guide/design-tokens/#design-tokens-themes-group /user-guide/design-systems/design-tokens/#design-tokens-themes
/user-guide/design-tokens/#design-tokens-import-export /user-guide/design-systems/design-tokens/#design-tokens-import-export
/user-guide/design-tokens/#design-tokens-import-options /user-guide/design-systems/design-tokens/#design-tokens-import-export
/user-guide/design-tokens/#design-tokens-export-options /user-guide/design-systems/design-tokens/#design-tokens-import-export
/user-guide/components/ /user-guide/design-systems/components/
/user-guide/components/#components-basics /user-guide/design-systems/components/
/user-guide/components/#component-create /user-guide/design-systems/components/#component-create
/user-guide/components/#component-find /user-guide/design-systems/components/#component-find
/user-guide/components/#component-main-components-page /user-guide/design-systems/components/#component-main-components-page
/user-guide/components/#working-with-components /user-guide/design-systems/components/#component-group
/user-guide/components/#component-group /user-guide/design-systems/components/#component-group
/user-guide/components/#component-detach /user-guide/design-systems/components/#component-detach
/user-guide/components/#component-annotate /user-guide/design-systems/components/#component-annotate
/user-guide/components/#component-overrides-relationships /user-guide/design-systems/components/#component-overrides
/user-guide/components/#component-overrides /user-guide/design-systems/components/#component-overrides
/user-guide/components/#component-update /user-guide/design-systems/components/#component-update
/user-guide/components/#component-swap /user-guide/design-systems/components/#component-swap
/user-guide/components/#component-variants /user-guide/design-systems/variants/
/user-guide/components/#component-variants-why-are-variants-important /user-guide/design-systems/variants/#component-variants-why-are-variants-important
/user-guide/components/#component-understanding-variants-properties-and-values /user-guide/design-systems/variants/#component-understanding-variants-properties-and-values
/user-guide/components/#component-create-and-modify-variants /user-guide/design-systems/variants/#component-create-and-modify-variants
/user-guide/components/#component-use-variants /user-guide/design-systems/variants/#component-use-variants
/user-guide/prototyping/ /user-guide/prototyping-testing/prototyping/
/user-guide/prototyping/#prototyping-connection /user-guide/prototyping-testing/prototyping/#prototyping-connection
/user-guide/prototyping/#prototype-anatomy /user-guide/prototyping-testing/prototyping/#prototype-anatomy
/user-guide/prototyping/#interaction-triggers /user-guide/prototyping-testing/prototyping/#interaction-triggers
/user-guide/prototyping/#prototyping-actions /user-guide/prototyping-testing/prototyping/#prototyping-actions
/user-guide/prototyping/#prototyping-actions-navigate /user-guide/prototyping-testing/prototyping/#prototyping-actions-navigate
/user-guide/prototyping/#prototyping-actions-overlay /user-guide/prototyping-testing/prototyping/#prototyping-actions-overlay
/user-guide/prototyping/#prototyping-actions-overlay-toggle /user-guide/prototyping-testing/prototyping/#prototyping-actions-overlay-toggle
/user-guide/prototyping/#prototyping-actions-overlay-close /user-guide/prototyping-testing/prototyping/#prototyping-actions-overlay-close
/user-guide/prototyping/#prototyping-actions-previous /user-guide/prototyping-testing/prototyping/#prototyping-actions-previous
/user-guide/prototyping/#prototyping-actions-url /user-guide/prototyping-testing/prototyping/#prototyping-actions-url
/user-guide/prototyping/#prototyping-animations /user-guide/prototyping-testing/prototyping/#prototyping-animations
/user-guide/prototyping/#prototyping-animations-dissolve /user-guide/prototyping-testing/prototyping/#prototyping-animations-dissolve
/user-guide/prototyping/#prototyping-animations-Slide /user-guide/prototyping-testing/prototyping/#prototyping-animations-Slide
/user-guide/prototyping/#prototyping-animations-push /user-guide/prototyping-testing/prototyping/#prototyping-animations-push
/user-guide/prototyping/#prototyping-flows /user-guide/prototyping-testing/prototyping/#prototyping-flows
/user-guide/prototyping/#prototyping-flows-starting /user-guide/prototyping-testing/prototyping/#prototyping-flows-starting
/user-guide/prototyping/#prototyping-flows-multiple /user-guide/prototyping-testing/prototyping/#prototyping-flows-multiple
/user-guide/prototyping/#prototyping-fix-scroll /user-guide/prototyping-testing/prototyping/#prototyping-fix-scroll
/user-guide/view-mode/ /user-guide/prototyping-testing/testing-view-mode/
/user-guide/view-mode/#viewmode-interface /user-guide/prototyping-testing/testing-view-mode/#viewmode-interface
/user-guide/view-mode/#viewmode-launch /user-guide/prototyping-testing/testing-view-mode/#viewmode-launch
/user-guide/view-mode/#viewmode-features /user-guide/prototyping-testing/testing-view-mode/#viewmode-features
/user-guide/view-mode/#viewmode-comments /user-guide/prototyping-testing/testing-view-mode/#viewmode-comments
/user-guide/view-mode/#viewmode-sharing /user-guide/prototyping-testing/testing-view-mode/#viewmode-sharing
/user-guide/view-mode/#viewmode-inspect /user-guide/prototyping-testing/testing-view-mode/#viewmode-inspect
/user-guide/inspect/ /user-guide/dev-tools/
/user-guide/inspect/#inspect-activate /user-guide/dev-tools/#inspect-design
/user-guide/inspect/#inspect-viewmode /user-guide/dev-tools/#inspect-design
/user-guide/inspect/#inspect-workspace /user-guide/dev-tools/#inspect-design
/user-guide/inspect/#inspect-measure /user-guide/dev-tools/#inspect-measure
/user-guide/inspect/#inspect-info /user-guide/dev-tools/#inspect-info
/user-guide/inspect/#inspect-copy /user-guide/dev-tools/#inspect-copy
/user-guide/inspect/#inspect-code /user-guide/dev-tools/#inspect-code
/user-guide/inspect/#inspect-export /user-guide/dev-tools/#inspect-export
/user-guide/import-export/ /user-guide/export-import/export-import-files/
/user-guide/import-export/#files-export /user-guide/export-import/export-import-files/#files-export
/user-guide/import-export/#export-penpot-files /user-guide/export-import/export-import-files/#files-export
/user-guide/import-export/#files-import /user-guide/export-import/export-import-files/#files-import
/user-guide/import-export/#penpot-formats /user-guide/export-import/export-import-files/#penpot-formats
/user-guide/teams/ /user-guide/account-teams/teams/
/user-guide/teams/#teams-management /user-guide/account-teams/teams/#teams-management
/user-guide/teams/#teams-members /user-guide/account-teams/teams/#teams-members
/user-guide/teams/#teams-invites /user-guide/account-teams/teams/#teams-invites
/user-guide/teams/#teams-webhooks /user-guide/plugins-integrations/#teams-webhooks
/user-guide/custom-fonts/ /user-guide/designing/text-typo/#custom-fonts
/user-guide/custom-fonts/#customfonts-upload /user-guide/designing/text-typo/#customfonts-upload
/user-guide/custom-fonts/#customfonts-families /user-guide/designing/text-typo/#customfonts-families
/user-guide/custom-fonts/#customfonts-edit /user-guide/designing/text-typo/#customfonts-edit
/user-guide/custom-fonts/#customfonts-using /user-guide/designing/text-typo/#customfonts-using
/user-guide/plugins/ /user-guide/plugins-integrations/
/user-guide/plugins/#plugins /user-guide/plugins-integrations/
/user-guide/plugins/#installation /user-guide/plugins-integrations/#installation
/user-guide/plugins/#hub-installation /user-guide/plugins-integrations/#installation
/user-guide/plugins/#url-installation /user-guide/plugins-integrations/#installation
/user-guide/plugins/#plugin-manager /user-guide/plugins-integrations/#plugin-manager
/user-guide/plugins/#using-plugins /user-guide/plugins-integrations/#using-plugins
/user-guide/plugins/#create-plugin /user-guide/plugins-integrations/#create-plugin

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