mirror of
https://github.com/penpot/penpot.git
synced 2026-09-15 00:19:01 +00:00
Replace match->context-params and mirrored-href with an in-place sync of the file-id, team-id and project-id keys on the parsed href: present ids are set, absent ones removed, every other param untouched. The backend keeps applying its own file, project and team priority. Rewrite router tests around a href-only stub: mirror, skip, stale-strip, clear, unrelated-param preservation, every-present-id, repeated-key and subpath cases. Update the link-preview subsystem doc. AI-assisted-by: muse-spark-1.3-contributor
304 lines
13 KiB
Markdown
304 lines
13 KiB
Markdown
---
|
|
title: Link previews
|
|
desc: How Penpot serves Open Graph metadata for shared links, so that Slack, Discord, Twitter and other platforms render rich previews with the file name and thumbnail.
|
|
---
|
|
|
|
# Link previews
|
|
|
|
When a user pastes a Penpot link in a chat or social platform (Slack, Discord,
|
|
Twitter/X, WhatsApp, Telegram, LinkedIn, Mastodon, Bluesky...), the platform's
|
|
crawler fetches the URL and looks for [Open Graph](https://ogp.me/) metadata to
|
|
render a rich preview card. This subsystem serves that metadata dynamically:
|
|
|
|
* For a **file** link: the file name as title and the latest dashboard
|
|
thumbnail of the file as preview image.
|
|
* For a **project** or **team** link: a generic "Project | Penpot" or
|
|
"Team dashboard | Penpot" title.
|
|
* In any other case (or when the feature is disabled): the default Penpot
|
|
title, description and preview image.
|
|
|
|
The whole feature is gated behind the `link-preview` flag (enabled with
|
|
`enable-link-preview` in `PENPOT_FLAGS`), and is **disabled by default**. See
|
|
[Security considerations](#security-considerations) below for why.
|
|
|
|
## How it works, end to end
|
|
|
|
The main obstacle is that Penpot is a SPA and all the routing state lives in
|
|
the URL **fragment** (`#/workspace?file-id=...`). The fragment is never sent to
|
|
the server, so with a plain URL the backend has no way to know which file the
|
|
link points to. The feature is therefore built from three cooperating pieces:
|
|
|
|
```text
|
|
user shares URL crawler (Slackbot, ...) regular browser
|
|
│ │ │
|
|
│ https://host/?file-id=X#/workspace?... │
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
[frontend] [nginx] [nginx]
|
|
mirrors context user-agent matches crawler user-agent is normal
|
|
params before the rewrite / -> /link-preview serve SPA index.html
|
|
fragment on every (query string preserved)
|
|
navigation │
|
|
▼
|
|
[backend]
|
|
GET /link-preview?file-id=X
|
|
query DB, render Open
|
|
Graph HTML template
|
|
```
|
|
|
|
### 1. Frontend: mirroring context params on the query string
|
|
|
|
File: `frontend/src/app/main/router.cljs`
|
|
|
|
On every navigation, the `navigated` event reads the freshly stored
|
|
`(:route state)` and syncs its `file-id`/`team-id`/`project-id` fragment
|
|
params into the query string (before the fragment) using
|
|
`history.replaceState`. Every other param in the URL is left untouched,
|
|
so unrelated params owned by other code survive. The write is skipped
|
|
when the resulting href already matches the address bar. The backend
|
|
applies its own file > project > team priority, so no filtering happens
|
|
on the frontend. The resulting URLs look like:
|
|
|
|
```text
|
|
https://design.penpot.app/?file-id=<uuid>#/workspace?team-id=...&file-id=...&page-id=...
|
|
https://design.penpot.app/?team-id=<uuid>&project-id=<uuid>#/dashboard/recent?...
|
|
https://design.penpot.app/?team-id=<uuid>#/dashboard/recent?team-id=...
|
|
```
|
|
|
|
Routes without any of those ids (e.g. auth pages) clear them from the
|
|
query string; `replaceState` only writes when the computed href differs
|
|
from the current one, so no URL churn happens on navigation.
|
|
|
|
This way, when the user copies the URL from the address bar and shares it, the
|
|
context ids travel in a part of the URL that *does* reach the server.
|
|
|
|
Legacy hash routes (`/workspace/:project-id/:file-id`, `/view/:file-id`,
|
|
`/dashboard/team/:team-id/...`) were removed: those old URLs no longer
|
|
redirect and resolve to the not-found page instead.
|
|
|
|
### 2. Nginx: detecting link preview crawlers
|
|
|
|
Files: `docker/devenv/files/nginx.conf` (devenv) and
|
|
`docker/images/files/nginx.conf.template` (production image).
|
|
|
|
A `map` block classifies the request by `User-Agent`:
|
|
|
|
```nginx
|
|
map $http_user_agent $penpot_link_preview_agent {
|
|
default 0;
|
|
~*(slackbot|discordbot|twitterbot|facebookexternalhit|facebookcatalog|whatsapp|telegrambot|linkedinbot|skypeuripreview|pinterestbot|redditbot|embedly|iframely|mastodon|bluesky) 1;
|
|
}
|
|
```
|
|
|
|
Inside the SPA root location, crawler requests for `/` are internally
|
|
rewritten to the backend link-preview endpoint (the query string is preserved by
|
|
`rewrite ... last`):
|
|
|
|
```nginx
|
|
if ($penpot_link_preview_agent) {
|
|
rewrite ^/$ /link-preview last;
|
|
}
|
|
|
|
location = /link-preview {
|
|
proxy_pass http://127.0.0.1:6060/link-preview$is_args$args; # devenv
|
|
# proxy_pass $PENPOT_BACKEND_URI/link-preview$is_args$args; # production template
|
|
}
|
|
```
|
|
|
|
Regular browsers are not affected: they keep receiving the SPA `index.html`.
|
|
If you self-host behind a different reverse proxy, you need to replicate this
|
|
routing there.
|
|
|
|
### 3. Backend: the `/link-preview` endpoint
|
|
|
|
File: `backend/src/app/http/link_preview.clj` (new namespace).
|
|
|
|
The handler:
|
|
|
|
1. If the `link-preview` flag is not set, skips any lookup and uses the
|
|
default context.
|
|
2. Otherwise parses `file-id` / `project-id` / `team-id` from the query
|
|
params. A present `file-id` is decisive: file links never fall through
|
|
to the project/team card, even when the value is a malformed or unknown
|
|
id (both yield the generic card); only a missing `file-id` key falls
|
|
through to project/team.
|
|
3. For a `file-id`, runs a single query joining `file` with its most recent
|
|
non-deleted `file_thumbnail` row (the dashboard thumbnail):
|
|
|
|
```sql
|
|
SELECT f.name, ft.media_id
|
|
FROM file AS f
|
|
LEFT JOIN file_thumbnail AS ft
|
|
ON (ft.file_id = f.id AND ft.deleted_at IS NULL)
|
|
WHERE f.id = ?
|
|
AND f.deleted_at IS NULL
|
|
ORDER BY ft.revn DESC NULLS LAST
|
|
LIMIT 1
|
|
```
|
|
|
|
4. Builds the context: `:title` is `"<file name> | Penpot"` and `:image` is
|
|
`<public-uri>/assets/by-id/<media-id>` when a thumbnail exists. Missing
|
|
data falls back to the defaults; the default image is
|
|
`<public-uri>/images/penpot-link-preview.png` (a static asset shipped in
|
|
`frontend/resources/public/images/`).
|
|
5. Renders `backend/resources/app/templates/link-preview.tmpl` and responds with
|
|
`200`, `text/html` and `cache-control: no-store, no-cache, max-age=0`.
|
|
|
|
The endpoint **always returns 200** with at least the generic metadata; a
|
|
non-existent file id, a malformed id or a disabled flag never produce an
|
|
error, so crawlers always get a valid preview.
|
|
|
|
The route is registered in `backend/src/app/http.clj` and wired in the
|
|
integrant system map in `backend/src/app/main.clj` (`::http.link-preview/routes`,
|
|
which only needs the `::db/pool` dependency). The route declares
|
|
`:allowed-methods #{:get :head}`, so other methods get a `405` from the shared
|
|
`restrict-methods` middleware.
|
|
|
|
### The HTML template
|
|
|
|
File: `backend/resources/app/templates/link-preview.tmpl`.
|
|
|
|
A minimal HTML page with `og:title`, `og:description`, `og:image`, the
|
|
equivalent `twitter:*` card tags and `<meta name="robots" content="noindex">`.
|
|
The body contains a single script:
|
|
|
|
```html
|
|
<script>location.replace("/" + location.search + location.hash);</script>
|
|
```
|
|
|
|
so that if a *human* somehow lands on `/link-preview` (e.g. some clients let users
|
|
click through to the fetched URL), the browser bounces back to the SPA root
|
|
keeping the query string and the fragment, and the app loads normally. Crawlers do not execute
|
|
JavaScript, so they just read the meta tags.
|
|
|
|
### Making file thumbnails publicly accessible
|
|
|
|
File: `backend/src/app/http/assets.clj`.
|
|
|
|
Crawlers fetch `og:image` anonymously, so the thumbnail asset must be served
|
|
without authentication. The assets handler decides per storage bucket whether
|
|
auth is required; with this feature the `file-thumbnail` bucket is treated as
|
|
public **only while the `link-preview` flag is enabled**:
|
|
|
|
```clojure
|
|
(defn- public-bucket?
|
|
[bucket]
|
|
(or (contains? public-buckets bucket)
|
|
(and (= "file-thumbnail" bucket)
|
|
(contains? cf/flags :link-preview))))
|
|
```
|
|
|
|
With the flag disabled, `file-thumbnail` objects keep requiring an
|
|
authenticated profile with access to the file, as before.
|
|
|
|
## The feature flag
|
|
|
|
Defined in `common/src/app/common/flags.cljc` as `:link-preview`, listed in the
|
|
`varia` set and **not** included in the default flags. Enable it on the
|
|
backend with:
|
|
|
|
```bash
|
|
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-preview"
|
|
```
|
|
|
|
It is a backend-only decision point; the frontend URL mirroring is always
|
|
active (it is harmless on its own), and the nginx crawler routing is also
|
|
unconditional — with the flag off the endpoint simply serves the generic
|
|
metadata.
|
|
|
|
## Security considerations
|
|
|
|
Enabling `link-preview` deliberately trades some privacy for shareability:
|
|
|
|
* **File names become readable by anyone who knows the file id** (the
|
|
`/link-preview` endpoint does no permission check).
|
|
* **Dashboard thumbnails become downloadable by anyone who knows the media
|
|
id** (the `file-thumbnail` bucket becomes public).
|
|
|
|
Both ids are random UUIDs, so they are not enumerable, but this is
|
|
knowledge-of-the-id access, not real authorization. This is the standard
|
|
trade-off that link preview features make; it is the reason the flag is off
|
|
by default and should be documented to self-hosters before they enable it.
|
|
|
|
The preview page also sets `robots: noindex` to keep search engines from
|
|
indexing these preview pages, and responses are marked non-cacheable.
|
|
|
|
## Testing it locally (devenv)
|
|
|
|
1. Make sure the devenv nginx picked up the config (restart the devenv, or
|
|
`nginx -s reload` inside the container, if it predates these changes).
|
|
|
|
2. The flag already ships enabled in devenv via `backend/scripts/_env`, so
|
|
no export is needed there; outside devenv, enable it before starting
|
|
the backend:
|
|
|
|
```bash
|
|
export PENPOT_FLAGS="$PENPOT_FLAGS enable-link-preview"
|
|
```
|
|
|
|
3. In the browser (`http://localhost:3449`), open a file in the workspace and
|
|
go back to the dashboard — leaving the workspace is what generates the
|
|
dashboard thumbnail. Verify the address bar now shows `?file-id=...`
|
|
before the `#`.
|
|
|
|
4. Hit the endpoint directly (bypasses the user-agent detection):
|
|
|
|
```bash
|
|
curl "http://localhost:3449/link-preview?file-id=<FILE_ID>"
|
|
```
|
|
|
|
Expect HTML with `og:title` containing the file name and `og:image`
|
|
pointing to `/assets/by-id/<media-id>` (or the default image if the file
|
|
has no thumbnail yet).
|
|
|
|
5. Simulate a real crawler against the root, exercising the full
|
|
nginx → rewrite → backend path:
|
|
|
|
```bash
|
|
curl -A "Slackbot-LinkExpanding 1.0" "http://localhost:3449/?file-id=<FILE_ID>"
|
|
```
|
|
|
|
The same URL with a normal user-agent must return the SPA `index.html`.
|
|
|
|
6. Verify the thumbnail is public:
|
|
|
|
```bash
|
|
curl -I "http://localhost:3449/assets/by-id/<MEDIA_ID>"
|
|
```
|
|
|
|
Expect `200` without any session cookie while the flag is on, and `401`
|
|
with the flag off (restart the backend after changing flags).
|
|
|
|
7. To see the actual preview card rendered by Slack/Discord you need a
|
|
publicly reachable URL (`og:image` is built from `PENPOT_PUBLIC_URI`), so
|
|
use a tunnel such as ngrok; for local verification the `curl` checks above
|
|
are enough.
|
|
|
|
## Automated tests
|
|
|
|
* `backend/test/backend_tests/http_link_preview_test.clj` — endpoint behavior:
|
|
default context, file with/without thumbnail, non-existent and malformed
|
|
file ids, deleted file, only-deleted thumbnail, latest-thumbnail revision
|
|
ordering, file-name HTML escaping, response headers, file-beats-project
|
|
priority, decisive file-id (malformed vs absent with a project id),
|
|
project and team links, and flag disabled.
|
|
* `backend/test/backend_tests/http_assets_test.clj`
|
|
(`objects-handler-file-thumbnail-bucket-link-preview-flag`) — the
|
|
`file-thumbnail` bucket is public only while the flag is enabled.
|
|
* `frontend/test/frontend_tests/router_test.cljs` — the `navigated` URL
|
|
surgery (mirror, skip, stale-strip, clear, unrelated-param
|
|
preservation, every-present-id, repeated-key, subpath base).
|
|
|
|
## Relevant files
|
|
|
|
| File | Role |
|
|
|---|---|
|
|
| `backend/src/app/http/link_preview.clj` | `/link-preview` handler: flag check, DB lookup, template rendering |
|
|
| `backend/resources/app/templates/link-preview.tmpl` | Open Graph HTML template + human redirect script |
|
|
| `backend/src/app/http/assets.clj` | Makes `file-thumbnail` bucket public under the flag |
|
|
| `backend/src/app/http.clj`, `backend/src/app/main.clj` | Route registration and system wiring |
|
|
| `common/src/app/common/flags.cljc` | `:link-preview` flag definition |
|
|
| `frontend/src/app/main/router.cljs` | Mirrors context ids on the query string on navigation |
|
|
| `docker/devenv/files/nginx.conf` | Devenv crawler detection and `/link-preview` routing |
|
|
| `docker/images/files/nginx.conf.template` | Same routing for the production image |
|