Compare commits

..

106 Commits

Author SHA1 Message Date
LongYinan
913a8fb36d Merge remote-tracking branch 'origin/canary' into beta 2024-03-27 15:32:45 +08:00
pengx17
d4c7d58b00 fix: use overflow: clip instead of js to prevent scrolling with pgup/pgdown (#6338) 2024-03-27 07:07:22 +00:00
LongYinan
8315908490 Merge remote-tracking branch 'origin/canary' into beta 2024-03-27 14:46:56 +08:00
pengx17
5ca17c155a fix(core): editor pgup/pgdn issues (#6331)
fix https://github.com/toeverything/AFFiNE/issues/6232
2024-03-27 04:47:32 +00:00
Brooooooklyn
5dcb3d69e5 fix(core): opt out telemetry if it was set to false (#6335) 2024-03-27 04:36:09 +00:00
EYHN
30b8b12703 fix(infra): fix sqlite not save data (#6336)
SQLiteDB will not save subdoc data that does not exist on rootdoc, so we must save rootdoc first, and then save subdoc
2024-03-27 04:25:23 +00:00
CatsJuice
a3cc06f3bb fix(core): optimize sidebar workspace card and avatar (#6324)
- adjust avatar size
- unlogged avatar dark-mode
- fix Tooltip console error
- optimize syncing status animation
2024-03-27 03:29:01 +00:00
EYHN
cccf864ed9 fix(core): duplicate window controls in trash (#6329)
fix https://github.com/toeverything/AFFiNE/issues/6310
2024-03-27 02:37:53 +00:00
forehalo
54c06777a6 fix(server): always set new session cookie (#6323) 2024-03-26 09:56:39 +00:00
forehalo
5637676222 fix(server): wrong import path (#6317) 2024-03-26 09:26:56 +00:00
EYHN
16063340d0 fix(core): fix meta.xxx is undefined (#6321) 2024-03-26 08:53:14 +00:00
EYHN
b6bba523ff fix(infra): large page list performance (#6319) 2024-03-26 07:53:53 +00:00
fundon
8ee9f6ec05 chore: improve password error message (#6255)
chore: improve error message

chore: add password minlength & maxlength i18n

chore: check max length

fix: i18n variables

feat: add CredentialsRequirementType
2024-03-26 07:15:06 +00:00
LongYinan
126bfe9c6e Merge remote-tracking branch 'origin/canary' into beta 2024-03-26 14:53:01 +08:00
liuyi
b8e6d7d6cb chore(server): cache blob list result (#6297) 2024-03-26 14:23:47 +08:00
CatsJuice
0731872347 feat(core): refactor sidebar header (#6251)
- Add user avatar
- Move sign-out/user settings link from workspace-modal to user avatar modal
- Modify the style of workspace list items
- Modify gap of navigation buttons
- Animate Syncing/Offline/...

![CleanShot 2024-03-22 at 10.22.38.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/7305f561-a85b-4ec6-89c2-27e2f1b63c85.gif)
2024-03-26 06:10:38 +00:00
Brooooooklyn
d8a3cd5ce2 chore: bump oxlint and rules (#6314) 2024-03-26 05:58:22 +00:00
liuyi
af2d895e78 chore(server): cache blob list result (#6297) 2024-03-26 13:49:24 +08:00
JimmFly
669ca325a1 fix(core): tag color should use palette-line-color (#6315) 2024-03-26 04:57:41 +00:00
pengx17
095f8c2359 fix: button should have its font-family inherited (#6311) 2024-03-26 03:57:59 +00:00
Brooooooklyn
ffbfdb65a2 fix(core): add env info to tracks (#6313) 2024-03-26 03:41:41 +00:00
pengx17
e9bc24bf37 fix(electron): possible issue on openning two main windows (#6307)
fix https://github.com/toeverything/AFFiNE/issues/6303

fetching `getWindowAdditionalArguments` requires forking a new process & handshake, which could be time consuming
2024-03-26 03:29:37 +00:00
renovate
2662ba763c chore: bump up express version to v4.19.2 [SECURITY] (#6308)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [express](http://expressjs.com/) ([source](https://togithub.com/expressjs/express)) | [`4.18.2` -> `4.19.2`](https://renovatebot.com/diffs/npm/express/4.18.2/4.19.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/express/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/express/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/express/4.18.2/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/express/4.18.2/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

### GitHub Vulnerability Alerts

#### [CVE-2024-29041](https://togithub.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vc)

### Impact

Versions of Express.js prior to 4.19.2 and pre-release alpha and beta versions before 5.0.0-beta.3 are affected by an open redirect vulnerability using malformed URLs.

When a user of Express performs a redirect using a user-provided URL Express performs an encode [using `encodeurl`](https://togithub.com/pillarjs/encodeurl) on the contents before passing it to the `location` header. This can cause malformed URLs to be evaluated in unexpected ways by common redirect allow list implementations in Express applications, leading to an Open Redirect via bypass of a properly implemented allow list.

The main method impacted is `res.location()` but this is also called from within `res.redirect()`.

### Patches

0867302ddb
0b746953c4

An initial fix went out with `express@4.19.0`, we then patched a feature regression in `4.19.1` and added improved handling for the bypass in `4.19.2`.

### Workarounds

The fix for this involves pre-parsing the url string with either `require('node:url').parse` or `new URL`. These are steps you can take on your own before passing the user input string to `res.location` or `res.redirect`.

### References

[https://github.com/expressjs/express/pull/5539](https://togithub.com/expressjs/express/pull/5539)
[https://github.com/koajs/koa/issues/1800](https://togithub.com/koajs/koa/issues/1800)
https://expressjs.com/en/4x/api.html#res.location

---

### Release Notes

<details>
<summary>expressjs/express (express)</summary>

### [`v4.19.2`](https://togithub.com/expressjs/express/blob/HEAD/History.md#4192--2024-03-25)

[Compare Source](https://togithub.com/expressjs/express/compare/4.19.1...4.19.2)

\==========

-   Improved fix for open redirect allow list bypass

### [`v4.19.1`](https://togithub.com/expressjs/express/blob/HEAD/History.md#4191--2024-03-20)

[Compare Source](https://togithub.com/expressjs/express/compare/4.19.0...4.19.1)

\==========

-   Allow passing non-strings to res.location with new encoding handling checks

### [`v4.19.0`](https://togithub.com/expressjs/express/compare/4.18.3...83e77aff6a3859d58206f3ff9501277023c03f87)

[Compare Source](https://togithub.com/expressjs/express/compare/4.18.3...4.19.0)

### [`v4.18.3`](https://togithub.com/expressjs/express/blob/HEAD/History.md#4183--2024-02-26)

[Compare Source](https://togithub.com/expressjs/express/compare/4.18.2...4.18.3)

\==========

-   Fix routing requests without method
-   deps: body-parser@1.20.2
    -   Fix strict json error message on Node.js 19+
    -   deps: content-type@~1.0.5
    -   deps: raw-body@2.5.2

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "" (UTC), 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 has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yNjkuMiIsInVwZGF0ZWRJblZlciI6IjM3LjI2OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5In0=-->
2024-03-26 03:17:49 +00:00
forehalo
1a1af83375 test(server): auth tests (#6135) 2024-03-26 02:24:17 +00:00
pengx17
1c9d899831 fix: runtime issue for electron app (#6306)
Looks like we need to be careful to share common libraries between electron (nodejs) & web

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7e568e47-2d61-45c8-8a1e-b933b63fd1a9.png)
2024-03-26 02:04:13 +00:00
pengx17
00092c9955 fix(electron): fix electron build (#6305) 2024-03-25 15:57:22 +00:00
EYHN
3e547ce4cc fix(core): hidden modals when workspace fallback (#6301) 2024-03-25 13:52:08 +00:00
EYHN
da12a0e48e fix(core): fix error when switch to local workspace (#6144) 2024-03-25 21:35:10 +08:00
CatsJuice
b2f34d17a2 feat(core): adjust app sidebar's style (#6162) 2024-03-25 10:25:48 +00:00
pengx17
2a019d4fae fix(core): storybook stability for date (#6300) 2024-03-25 09:50:48 +00:00
donteatfriedrice
48abc52e85 feat: bump blocksuite (#6294)
## Features
- https://github.com/toeverything/BlockSuite/pull/6544 @golok727
- https://github.com/toeverything/BlockSuite/pull/6543 @golok727
- https://github.com/toeverything/BlockSuite/pull/6536 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6497 @doouding
- https://github.com/toeverything/BlockSuite/pull/6514 @regischen
- https://github.com/toeverything/BlockSuite/pull/6523 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6530 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/6526 @fourdim
- https://github.com/toeverything/BlockSuite/pull/6532 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6493 @golok727
- https://github.com/toeverything/BlockSuite/pull/6529 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/6528 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/6509 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/6525 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6502 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6489 @Flrande

## Bugfix
- https://github.com/toeverything/BlockSuite/pull/6558 @fourdim
- https://github.com/toeverything/BlockSuite/pull/6556 @fourdim
- https://github.com/toeverything/BlockSuite/pull/6547 @fundon
- https://github.com/toeverything/BlockSuite/pull/6537 @golok727
- https://github.com/toeverything/BlockSuite/pull/6531 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6524 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6519 @regischen
- https://github.com/toeverything/BlockSuite/pull/6517 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6516 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6510 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6511 @congzhou09
- https://github.com/toeverything/BlockSuite/pull/6507 @doouding
- https://github.com/toeverything/BlockSuite/pull/6500 @fourdim
- https://github.com/toeverything/BlockSuite/pull/6486 @congzhou09
- https://github.com/toeverything/BlockSuite/pull/6495 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/6488 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/6482 @Flrande
- https://github.com/toeverything/BlockSuite/pull/6558 @fourdim

## Refactor
- https://github.com/toeverything/BlockSuite/pull/6548 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6522 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6518 @regischen
- https://github.com/toeverything/BlockSuite/pull/6521 @Saul-Mirone

## Misc
- https://github.com/toeverything/BlockSuite/pull/6557 @fourdim
- https://github.com/toeverything/BlockSuite/pull/6546 @Flrande
- docs: update package desc
- https://github.com/toeverything/BlockSuite/pull/6527 @fourdim
- https://github.com/toeverything/BlockSuite/pull/6505 @Brooooooklyn
- https://github.com/toeverything/BlockSuite/pull/6503 @fourdim
- v0.13.0
- https://github.com/toeverything/BlockSuite/pull/6496 @doodlewind
- https://github.com/toeverything/BlockSuite/pull/6562 @donteatfriedrice
2024-03-25 09:20:45 +00:00
JimmFly
09a27b6c25 feat(core): add remove from collection to collection page list (#6265)
close AFF-246
2024-03-25 08:31:38 +00:00
JimmFly
03c01a9693 fix(core): edit tag input autofocus (#6296)
close TOV-724
2024-03-25 08:05:25 +00:00
JimmFly
1ff6af85f5 feat(core): add page group and display properties (#6228)
close TOV-23

https://github.com/toeverything/AFFiNE/assets/102217452/c05474de-b73c-40ab-9f18-cc43bb9fd828
2024-03-25 07:53:33 +00:00
Brooooooklyn
6467e10690 ci: fix lint oom (#6295) 2024-03-25 07:11:49 +00:00
EYHN
a8cd1579f5 feat(infra): livedata effect (#6281) 2024-03-25 06:09:45 +00:00
EYHN
f2adbdaba4 style: enable import-x/no-duplicates (#6279) 2024-03-25 03:55:33 +00:00
EYHN
7ce2bfbf0b style: no import infra submodule (#6278) 2024-03-25 03:55:29 +00:00
EYHN
b93871f045 feat(electron): define runtimeConfig in esbuild (#6287) 2024-03-25 03:55:26 +00:00
EYHN
d59e1389ec chore(electron): config vitest swc (#6282) 2024-03-25 03:55:23 +00:00
EYHN
82cacd09d6 fix(core): fix flaky e2e (#6293) 2024-03-25 02:58:52 +00:00
pengx17
578d4c9775 fix(core): image preview flaky (#6292) 2024-03-25 02:46:31 +00:00
pengx17
64c011c72f fix(electron): set referer and origin headers for electron (#6289) 2024-03-25 01:23:18 +00:00
EYHN
2b42a75e5a style: enable rxjs/finnish (#6276)
chore(infra): use finnish notation for observables

do rename
2024-03-24 17:04:51 +00:00
dependabot
c6676fd074 build(deps): bump webpack-dev-middleware from 7.0.0 to 7.1.1 (#6275)
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 7.0.0 to 7.1.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/webpack/webpack-dev-middleware/releases">webpack-dev-middleware's releases</a>.</em></p>
<blockquote>
<h2>v7.1.1</h2>
<h3><a href="https://github.com/webpack/webpack-dev-middleware/compare/v7.1.0...v7.1.1">7.1.1</a> (2024-03-21)</h3>
<h3>Bug Fixes</h3>
<ul>
<li><code>ContentLength</code> incorrectly set for empty files (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1785">#1785</a>) (<a href="0f3e25e2b0">0f3e25e</a>)</li>
<li>improve perf (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1777">#1777</a>) (<a href="5b47c9294e">5b47c92</a>)</li>
<li><strong>types:</strong> make types better (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1786">#1786</a>) (<a href="e4d183ea6d">e4d183e</a>)</li>
</ul>
<h2>v7.1.0</h2>
<h2><a href="https://github.com/webpack/webpack-dev-middleware/compare/v7.0.0...v7.1.0">7.1.0</a> (2024-03-19)</h2>
<h3>Features</h3>
<ul>
<li>prefer to use <code>fs.createReadStream</code> over <code>fs.readFileSync</code> to read files (<a href="ab533de933">ab533de</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>cleaup stream and handle errors (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1769">#1769</a>) (<a href="1258fdd3d9">1258fdd</a>)</li>
<li><strong>security:</strong> do not allow to read files above (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1771">#1771</a>) (<a href="e10008c762">e10008c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/webpack/webpack-dev-middleware/blob/master/CHANGELOG.md">webpack-dev-middleware's changelog</a>.</em></p>
<blockquote>
<h3><a href="https://github.com/webpack/webpack-dev-middleware/compare/v7.1.0...v7.1.1">7.1.1</a> (2024-03-21)</h3>
<h3>Bug Fixes</h3>
<ul>
<li><code>ContentLength</code> incorrectly set for empty files (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1785">#1785</a>) (<a href="0f3e25e2b0">0f3e25e</a>)</li>
<li>improve perf (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1777">#1777</a>) (<a href="5b47c9294e">5b47c92</a>)</li>
<li><strong>types:</strong> make types better (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1786">#1786</a>) (<a href="e4d183ea6d">e4d183e</a>)</li>
</ul>
<h2><a href="https://github.com/webpack/webpack-dev-middleware/compare/v7.0.0...v7.1.0">7.1.0</a> (2024-03-19)</h2>
<h3>Features</h3>
<ul>
<li>prefer to use <code>fs.createReadStream</code> over <code>fs.readFileSync</code> to read files (<a href="ab533de933">ab533de</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>cleaup stream and handle errors (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1769">#1769</a>) (<a href="1258fdd3d9">1258fdd</a>)</li>
<li><strong>security:</strong> do not allow to read files above (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1771">#1771</a>) (<a href="e10008c762">e10008c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="7c6164a82f"><code>7c6164a</code></a> chore(release): 7.1.1</li>
<li><a href="e4d183ea6d"><code>e4d183e</code></a> fix(types): make types better (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1786">#1786</a>)</li>
<li><a href="f23ed7ccd8"><code>f23ed7c</code></a> chore(deps-dev): bump <code>@​babel/core</code> from 7.24.1 to 7.24.3 (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1782">#1782</a>)</li>
<li><a href="0f3e25e2b0"><code>0f3e25e</code></a> fix: <code>ContentLength</code> incorrectly set for empty files (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1785">#1785</a>)</li>
<li><a href="d45f033ea7"><code>d45f033</code></a> chore(deps-dev): bump <code>@​babel/preset-env</code> from 7.24.1 to 7.24.3 (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1783">#1783</a>)</li>
<li><a href="c0c2eea2e7"><code>c0c2eea</code></a> chore(deps-dev): bump express from 4.18.3 to 4.19.1 (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1781">#1781</a>)</li>
<li><a href="5b47c9294e"><code>5b47c92</code></a> fix: improve perf (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1777">#1777</a>)</li>
<li><a href="1a34bc4bce"><code>1a34bc4</code></a> chore(deps-dev): bump <code>@​types/node</code> from 20.11.29 to 20.11.30 (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1774">#1774</a>)</li>
<li><a href="d618f1f126"><code>d618f1f</code></a> chore(deps-dev): bump <code>@​babel/preset-env</code> from 7.24.0 to 7.24.1 (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1772">#1772</a>)</li>
<li><a href="40daa4bb71"><code>40daa4b</code></a> chore(deps-dev): bump <code>@​babel/core</code> from 7.24.0 to 7.24.1 (<a href="https://redirect.github.com/webpack/webpack-dev-middleware/issues/1776">#1776</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/webpack/webpack-dev-middleware/compare/v7.0.0...v7.1.1">compare view</a></li>
</ul>
</details>
<br />

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=webpack-dev-middleware&package-manager=npm_and_yarn&previous-version=7.0.0&new-version=7.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/toeverything/AFFiNE/network/alerts).

</details>
2024-03-24 11:02:10 +00:00
BABA
6a02d0bc96 feat: open about page in setting modal when click about menu (#6245)
Co-authored-by: EYHN <cneyhn@gmail.com>
2024-03-23 13:27:05 +00:00
Fangdun Tsai
6c9db367e2 chore(core): add oauth connecting state (#6225) 2024-03-23 21:18:48 +08:00
BABA
a1532d4df2 chore: fix renderer entry path not found in desktop development (#6270)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-03-23 21:12:23 +08:00
fundon
7e161682f0 fix(core): creating multiple workspaces with consecutive clicks (#6259)
Closes #6213
2024-03-23 12:29:46 +00:00
pengx17
62a6075675 fix(core): do not ensure properties on read (#6263) 2024-03-23 12:15:06 +00:00
JimmFly
532d655ffb feat(core): add confirm modal for delete tag action (#6268) 2024-03-23 12:03:14 +00:00
pengx17
3c6983ee49 fix(core): storybook build issue (#6274)
1. es2022 is required and should be set separately in storybook.
2. @blocksuite/icons versions are not consistent across packages.
2024-03-23 06:33:25 +00:00
EYHN
34703a3b7d feat(infra): new doc sync engine (#6205)
https://github.com/toeverything/AFFiNE/blob/eyhn/feat/new-sync/packages/common/infra/src/workspace/engine/doc/README.md
2024-03-22 16:43:26 +00:00
Brooooooklyn
05c44db5a9 chore(core): remove unused dependencies (#6203) 2024-03-22 10:39:39 +00:00
Brooooooklyn
622e90f176 chore(core): add telemetry switch (#6267) 2024-03-22 10:28:55 +00:00
EYHN
a0b97f948c fix(core): fix stuttering when change doc title (#6269) 2024-03-22 10:06:37 +00:00
Fangdun Tsai
69cb8b0f60 chore(core): disable onborading on the web (#6222) 2024-03-22 18:05:36 +08:00
Brooooooklyn
150c22936d chore(core): add mixpanel track (#6202) 2024-03-22 09:24:41 +00:00
Brooooooklyn
10af0ab48d feat(server): support ai plan (#6216) 2024-03-22 08:39:18 +00:00
Brooooooklyn
aecc523663 fix(server): avoid error when other prices added but logic is not released (#6191) 2024-03-22 08:39:12 +00:00
EYHN
75355867c7 feat(core): save user habits in right sidebar (#6262)
Closes #6237
2024-03-22 07:32:59 +00:00
Brooooooklyn
85ee22329c fix(electron): add icon for AppImage build (#6257)
1. the icon is fixed in `/Applications`: 128b8c22f9 (diff-a694a3e854f53b066e34ec310e05bd18b4944c016455f6963f54a351784d5fa6L91)
2. the App's icon MUST be 64x64 png and set via `setIcon`

![image](https://github.com/toeverything/AFFiNE/assets/584378/bbce0007-066b-413f-a85a-193acbbe5c13)
2024-03-21 14:29:02 +00:00
forehalo
540e456704 ci: set private key from env (#6239) 2024-03-21 10:09:26 +00:00
EYHN
d03c72a0a8 fix(electron): linux crash on exiting presentation mode (#6253) 2024-03-21 09:54:48 +00:00
Brooooooklyn
6a0ab54e25 ci: fix isSelfHosted does not take effect (#6249) 2024-03-21 08:52:14 +00:00
Brooooooklyn
18224a83d1 chore(electron): bump @napi-rs/macos-alias (#6240)
fix dmg background missing issue https://github.com/Brooooooklyn/macos-alias/pull/3
2024-03-21 06:53:14 +00:00
pengx17
f4ede22b93 fix(core): change cursor when hovering the area blow editor (#6226) 2024-03-21 02:00:37 +00:00
pengx17
8b2b2646bc fix: move traffic lights based on zoom level (#6201)
<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/f75d1f6f-18f4-4dff-8174-67223f5f9807.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/f75d1f6f-18f4-4dff-8174-67223f5f9807.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/f75d1f6f-18f4-4dff-8174-67223f5f9807.mp4">Kapture 2024-03-19 at 18.05.20.mp4</video>
2024-03-21 02:00:35 +00:00
EYHN
e1cfa1071e chore(core): align sidebar icons (#6219) 2024-03-20 17:15:13 +00:00
EYHN
e4e4a54d90 fix(core): resize-handle remains interactive when dragging split-view (#6217) 2024-03-20 16:56:17 +00:00
EYHN
08b610bbad fix(electron): menu item position on Mac when fullscreen (#6200)
fix https://github.com/toeverything/AFFiNE/issues/6155
2024-03-20 16:45:14 +00:00
EYHN
3edf32b1df build: add sourceMaps and inlineSourcesContent option to swc (#6234) 2024-03-20 16:33:15 +00:00
EYHN
39cde560d1 fix(templates): fix typo in onboarding template (#6221) 2024-03-20 16:19:28 +00:00
Brooooooklyn
483f957583 chore: bump up @aws-sdk/client-s3 version to v3.537.0 (#6210)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@aws-sdk/client-s3](https://togithub.com/aws/aws-sdk-js-v3/tree/main/clients/client-s3) ([source](https://togithub.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3)) | [`3.536.0` -> `3.537.0`](https://renovatebot.com/diffs/npm/@aws-sdk%2fclient-s3/3.536.0/3.537.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@aws-sdk%2fclient-s3/3.537.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@aws-sdk%2fclient-s3/3.537.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@aws-sdk%2fclient-s3/3.536.0/3.537.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@aws-sdk%2fclient-s3/3.536.0/3.537.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>aws/aws-sdk-js-v3 (@&#8203;aws-sdk/client-s3)</summary>

### [`v3.537.0`](https://togithub.com/aws/aws-sdk-js-v3/blob/HEAD/clients/client-s3/CHANGELOG.md#35370-2024-03-19)

[Compare Source](https://togithub.com/aws/aws-sdk-js-v3/compare/v3.536.0...v3.537.0)

**Note:** Version bump only for package [@&#8203;aws-sdk/client-s3](https://togithub.com/aws-sdk/client-s3)

</details>

---

### Configuration

📅 **Schedule**: 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 has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yNDUuMCIsInVwZGF0ZWRJblZlciI6IjM3LjI0NS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5In0=-->
2024-03-20 14:44:24 +00:00
Brooooooklyn
32ab0693e2 feat(core): update split view icons and texts (#6193) 2024-03-20 14:30:35 +00:00
pengx17
a8a1074a8a feat(electron): add isMaximized flag to html (#6199)
to make some special ui rules for desktop
2024-03-20 13:20:19 +00:00
pengx17
65ab6c89bf fix(electron): optimize electron open/close on mac (#6224)
1. never close main window on mac to allow it to be quickly open
1. make the browser show a bit faster
2. brought up app window when clicking some menu items
2024-03-20 11:02:22 +00:00
liuyi
4f5907766f fix(server): decode uri component before verify token (#6231) 2024-03-20 18:17:11 +08:00
liuyi
06a5b2e5a5 fix(server): wrong google oauth param (#6227) 2024-03-20 17:45:22 +08:00
pengx17
7adb89f134 feat(core): open new page on meta-clicking a page link (#6220) 2024-03-20 05:38:39 +00:00
EYHN
5623c0967c feat(electron): enable css text autospace (#6218)
before

![CleanShot 2024-03-20 at 10.38.50@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/80a59a2b-ede7-453a-889a-6c54a967c27d.png)

after

![CleanShot 2024-03-20 at 10.39.08@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/5e98d9e1-ec8e-4cc7-804b-e4347c62ee6e.png)
2024-03-20 02:47:49 +00:00
Peng Xiao
fce4484a85 fix(core): tag size in docs view (#6197) 2024-03-19 08:53:08 +00:00
Peng Xiao
0695544073 fix(core): page info should use sans font (inter) (#6196) 2024-03-19 08:53:00 +00:00
JimmFly
9030ca511e refactor(core): refactor tag to use di (#6079)
use case
```
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags);
const currentTagLiveData = tagService.tagByTagId(tagId);
const currentTag = useLiveData(currentTagLiveData);

```
2024-03-19 08:39:15 +00:00
LongYinan
332cd3b380 refactor(core): split web entry from core (#6082)
This pr is trying to split `web` and `electron` entries from `core`. It allows more platform-related optimization to be addressed in each entry.
We should remove all browser/electron only codes from `core` eventually, this is the very first step for that.
2024-03-19 07:48:56 +00:00
LongYinan
26925c96e4 chore: bump up happy-dom version to v14 (#6187)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [happy-dom](https://togithub.com/capricorn86/happy-dom) | [`^13.4.1` -> `^14.0.0`](https://renovatebot.com/diffs/npm/happy-dom/13.4.1/14.0.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/happy-dom/14.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/happy-dom/14.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/happy-dom/13.4.1/14.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/happy-dom/13.4.1/14.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>capricorn86/happy-dom (happy-dom)</summary>

### [`v14.0.0`](https://togithub.com/capricorn86/happy-dom/releases/tag/v14.0.0)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.10.1...v14.0.0)

##### 💣 Breaking Changes

-   Removes interfaces for Node's, as they are no longer needed as newer versions of Typescript can handle circular dependencies - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1330](https://togithub.com/capricorn86/happy-dom/issues/1330)

### [`v13.10.1`](https://togithub.com/capricorn86/happy-dom/compare/v13.10.0...a6debf50e909766e0e5442b9e4c5ebe8dadb1cd1)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.10.0...v13.10.1)

### [`v13.10.0`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.10.0)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.9.0...v13.10.0)

##### 🎨 Features

-   Adds support for the Headers.getSetCookie - By **[@&#8203;betterqualityassuranceuser](https://togithub.com/betterqualityassuranceuser)** in task [#&#8203;1315](https://togithub.com/capricorn86/happy-dom/issues/1315)

### [`v13.9.0`](https://togithub.com/capricorn86/happy-dom/compare/v13.8.6...9d6d1f39aeb2cbfce914277ce22264ee88290582)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.6...v13.9.0)

### [`v13.8.6`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.8.6)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.5...v13.8.6)

##### 👷‍♂️ Patch fixes

-   Fixes bug related to multiple fallbacks to CSS variables being set incorrectly - By **[@&#8203;odanado](https://togithub.com/odanado)** in task [#&#8203;1308](https://togithub.com/capricorn86/happy-dom/issues/1308)

### [`v13.8.5`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.8.5)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.4...v13.8.5)

##### 👷‍♂️ Patch fixes

-   Fixes problem related to invalid pseudo query selectors matching elements (e.g. ":before" should only match the pseudo element and not the actual element) - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1122](https://togithub.com/capricorn86/happy-dom/issues/1122)
-   Adds support for using multiple pseudo query selectors (e.g. ":first-of-type:last-of-type") - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1122](https://togithub.com/capricorn86/happy-dom/issues/1122)
-   Fixes minor typo in `HTMLElementConfig` - By **[@&#8203;danbentley](https://togithub.com/danbentley)** in task [#&#8203;1306](https://togithub.com/capricorn86/happy-dom/issues/1306)

### [`v13.8.4`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.8.4)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.3...v13.8.4)

##### 👷‍♂️ Patch fixes

-   Adds support for returning URL relative to window location in `HTMLLinkElement.href`, `HTMLImageElement.src` and `HTMLScriptElement.src` - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1135](https://togithub.com/capricorn86/happy-dom/issues/1135)

### [`v13.8.3`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.8.3)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.2...v13.8.3)

##### 👷‍♂️ Patch fixes

-   Fixes problem where some elements (e.g. `<li>`, `<h1>` or `<table>`) doesn't allow itself as direct descendant when parsing HTML, but should allow itself as descendant when it is not at first level - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1039](https://togithub.com/capricorn86/happy-dom/issues/1039)

### [`v13.8.2`](https://togithub.com/capricorn86/happy-dom/compare/v13.8.1...4970c699d07d97c4a9839e25c831eef230445abf)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.1...v13.8.2)

### [`v13.8.1`](https://togithub.com/capricorn86/happy-dom/compare/v13.8.0...08cd42601d62f39d42d01d902a56d2441f7128e0)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.8.0...v13.8.1)

### [`v13.8.0`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.8.0)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.8...v13.8.0)

##### 🎨 Features

-   Adds support for Element.scrollIntoView - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1051](https://togithub.com/capricorn86/happy-dom/issues/1051)

### [`v13.7.8`](https://togithub.com/capricorn86/happy-dom/compare/v13.7.7...0dfe51d6006c09b2f12ec2ec4f15858ae6450060)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.7...v13.7.8)

### [`v13.7.7`](https://togithub.com/capricorn86/happy-dom/compare/v13.7.6...v13.7.7)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.6...v13.7.7)

### [`v13.7.6`](https://togithub.com/capricorn86/happy-dom/compare/v13.7.5...54d1ae080f4e91ae09bb586ad01f82050cf5db15)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.5...v13.7.6)

### [`v13.7.5`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.7.5)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.4...v13.7.5)

##### 👷‍♂️ Patch fixes

-   Modify option node to return empty string even if the value is empty string - In task [#&#8203;1138](https://togithub.com/capricorn86/happy-dom/issues/1138)

### [`v13.7.4`](https://togithub.com/capricorn86/happy-dom/compare/v13.7.3...16396f9d1f114ad70c926f56da40a31382aeabcb)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.3...v13.7.4)

### [`v13.7.3`](https://togithub.com/capricorn86/happy-dom/compare/v13.7.2...1bd90205d67aa78de52ea5d1ebb3c8f8db2364af)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.2...v13.7.3)

### [`v13.7.2`](https://togithub.com/capricorn86/happy-dom/compare/v13.7.1...3b4339d709bb9b097a8302996dc4af356f496e1a)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.1...v13.7.2)

### [`v13.7.1`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.7.1)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.7.0...v13.7.1)

##### 👷‍♂️ Patch fixes

-   Adds support for cloning body in `Response.clone()` - By **[@&#8203;cprecioso](https://togithub.com/cprecioso)** in task [#&#8203;1216](https://togithub.com/capricorn86/happy-dom/issues/1216)

### [`v13.7.0`](https://togithub.com/capricorn86/happy-dom/compare/v13.6.2...4c808b62f8dcfb5c85d4ac4e94b8e2ba58195e86)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.6.2...v13.7.0)

### [`v13.6.2`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.6.2)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.6.1...v13.6.2)

##### 🎨 Features

-   Add support for the ":target" pseudo query selector - By **[@&#8203;Schleuse](https://togithub.com/Schleuse)** in task [#&#8203;1221](https://togithub.com/capricorn86/happy-dom/issues/1221)

##### 👷‍♂️ Patch fixes

-   The Event listener method `handleEvent()` should be called within the listener scope - By **[@&#8203;titouanmathis](https://togithub.com/titouanmathis)** in task [#&#8203;1182](https://togithub.com/capricorn86/happy-dom/issues/1182)

### [`v13.6.1`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.6.1)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.6.0...v13.6.1)

##### 👷‍♂️ Patch fixes

-   Improves validation for the options argument in `MutationsObserver.observe()` - By **[@&#8203;romansp](https://togithub.com/romansp)** in task [#&#8203;1223](https://togithub.com/capricorn86/happy-dom/issues/1223)

### [`v13.6.0`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.6.0)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.5.3...v13.6.0)

##### 🎨 Features

-   Adds support for `Node.isEqualNode()` - By **[@&#8203;aralroca](https://togithub.com/aralroca)** in task [#&#8203;1263](https://togithub.com/capricorn86/happy-dom/issues/1263)

##### 👷‍♂️ Patch fixes

-   Adds support for the property `Document.forms` - By **[@&#8203;juandiegombr](https://togithub.com/juandiegombr)** in task [#&#8203;1260](https://togithub.com/capricorn86/happy-dom/issues/1260)
-   Adds check for if `MutationObserver` options are null, which most likely happens for code that is executed after the Window instance has been closed - By **[@&#8203;zachlankton](https://togithub.com/zachlankton)** in task [#&#8203;1217](https://togithub.com/capricorn86/happy-dom/issues/1217)

### [`v13.5.3`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.5.3)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.5.2...v13.5.3)

##### 👷‍♂️ Patch fixes

-   Improves check for invalid query selectors - By **[@&#8203;btea](https://togithub.com/btea)** in task #&#8203;0

### [`v13.5.2`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.5.2)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.5.1...v13.5.2)

##### 👷‍♂️ Patch fixes

-   Adds unit test for Vue component with SVG - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1271](https://togithub.com/capricorn86/happy-dom/issues/1271)

### [`v13.5.1`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.5.1)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.5.0...v13.5.1)

##### 👷‍♂️ Patch fixes

-   Fixes problem with query selectors not finding SVG elements after the v13.4.0 release - By **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1274](https://togithub.com/capricorn86/happy-dom/issues/1274)

### [`v13.5.0`](https://togithub.com/capricorn86/happy-dom/releases/tag/v13.5.0)

[Compare Source](https://togithub.com/capricorn86/happy-dom/compare/v13.4.1...v13.5.0)

##### 🎨 Features

-   Use the Node.js `ReadableStream` class  for the properties `Response.body` and `Request.body` - By **[@&#8203;diego-toro](https://togithub.com/diego-toro)** and **[@&#8203;capricorn86](https://togithub.com/capricorn86)** in task [#&#8203;1180](https://togithub.com/capricorn86/happy-dom/issues/1180)
    -   The previous implementation used the Node.js `Stream.Readable` class, which is not fully spec compliant

</details>

---

### Configuration

📅 **Schedule**: 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 has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yNDUuMCIsInVwZGF0ZWRJblZlciI6IjM3LjI0NS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5In0=-->
2024-03-19 07:34:32 +00:00
LongYinan
398d66fac1 chore: bump up all non-major dependencies (#6107)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@aws-sdk/client-s3](https://togithub.com/aws/aws-sdk-js-v3/tree/main/clients/client-s3) ([source](https://togithub.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3)) | [`3.529.1` -> `3.536.0`](https://renovatebot.com/diffs/npm/@aws-sdk%2fclient-s3/3.529.1/3.536.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@aws-sdk%2fclient-s3/3.536.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@aws-sdk%2fclient-s3/3.536.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@aws-sdk%2fclient-s3/3.529.1/3.536.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@aws-sdk%2fclient-s3/3.529.1/3.536.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@nx/vite](https://nx.dev) ([source](https://togithub.com/nrwl/nx/tree/HEAD/packages/vite)) | [`18.0.8` -> `18.1.2`](https://renovatebot.com/diffs/npm/@nx%2fvite/18.0.8/18.1.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@nx%2fvite/18.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@nx%2fvite/18.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@nx%2fvite/18.0.8/18.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nx%2fvite/18.0.8/18.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@vitest/coverage-istanbul](https://togithub.com/vitest-dev/vitest/tree/main/packages/coverage-istanbul#readme) ([source](https://togithub.com/vitest-dev/vitest/tree/HEAD/packages/coverage-istanbul)) | [`1.3.1` -> `1.4.0`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-istanbul/1.3.1/1.4.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-istanbul/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@vitest%2fcoverage-istanbul/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@vitest%2fcoverage-istanbul/1.3.1/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-istanbul/1.3.1/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [@vitest/ui](https://togithub.com/vitest-dev/vitest/tree/main/packages/ui#readme) ([source](https://togithub.com/vitest-dev/vitest/tree/HEAD/packages/ui)) | [`1.3.1` -> `1.4.0`](https://renovatebot.com/diffs/npm/@vitest%2fui/1.3.1/1.4.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fui/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@vitest%2fui/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@vitest%2fui/1.3.1/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fui/1.3.1/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [vitest](https://togithub.com/vitest-dev/vitest) ([source](https://togithub.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`1.3.1` -> `1.4.0`](https://renovatebot.com/diffs/npm/vitest/1.3.1/1.4.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/vitest/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/vitest/1.3.1/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/1.3.1/1.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>aws/aws-sdk-js-v3 (@&#8203;aws-sdk/client-s3)</summary>

### [`v3.536.0`](https://togithub.com/aws/aws-sdk-js-v3/blob/HEAD/clients/client-s3/CHANGELOG.md#35360-2024-03-18)

[Compare Source](https://togithub.com/aws/aws-sdk-js-v3/compare/v3.535.0...v3.536.0)

**Note:** Version bump only for package [@&#8203;aws-sdk/client-s3](https://togithub.com/aws-sdk/client-s3)

### [`v3.535.0`](https://togithub.com/aws/aws-sdk-js-v3/blob/HEAD/clients/client-s3/CHANGELOG.md#35350-2024-03-15)

[Compare Source](https://togithub.com/aws/aws-sdk-js-v3/compare/v3.534.0...v3.535.0)

**Note:** Version bump only for package [@&#8203;aws-sdk/client-s3](https://togithub.com/aws-sdk/client-s3)

### [`v3.534.0`](https://togithub.com/aws/aws-sdk-js-v3/blob/HEAD/clients/client-s3/CHANGELOG.md#35340-2024-03-14)

[Compare Source](https://togithub.com/aws/aws-sdk-js-v3/compare/v3.533.0...v3.534.0)

**Note:** Version bump only for package [@&#8203;aws-sdk/client-s3](https://togithub.com/aws-sdk/client-s3)

### [`v3.533.0`](https://togithub.com/aws/aws-sdk-js-v3/blob/HEAD/clients/client-s3/CHANGELOG.md#35330-2024-03-13)

[Compare Source](https://togithub.com/aws/aws-sdk-js-v3/compare/v3.529.1...v3.533.0)

##### Features

-   **client-s3:** This release makes the default option for S3 on Outposts request signing to use the SigV4A algorithm when using AWS Common Runtime (CRT). ([2ddd8ec](2ddd8ec13e))

#### [3.529.1](https://togithub.com/aws/aws-sdk-js-v3/compare/v3.529.0...v3.529.1) (2024-03-08)

**Note:** Version bump only for package [@&#8203;aws-sdk/client-s3](https://togithub.com/aws-sdk/client-s3)

</details>

<details>
<summary>nrwl/nx (@&#8203;nx/vite)</summary>

### [`v18.1.2`](https://togithub.com/nrwl/nx/releases/tag/18.1.2)

[Compare Source](https://togithub.com/nrwl/nx/compare/18.1.1...18.1.2)

##### 18.1.2 (2024-03-18)

##### 🚀 Features

-   **nx-dev:** add apollo.io tracking script to `_app.tsx` ([#&#8203;22339](https://togithub.com/nrwl/nx/pull/22339))

##### 🩹 Fixes

-   **core:** do not use pseudo terminal if platform is unsuported and f… ([#&#8203;22341](https://togithub.com/nrwl/nx/pull/22341))
-   **js:** read lockfile from the workspace root ([#&#8203;22340](https://togithub.com/nrwl/nx/pull/22340))

##### ❤️  Thank You

-   Benjamin Cabanes [@&#8203;bcabanes](https://togithub.com/bcabanes)
-   Jason Jean [@&#8203;FrozenPandaz](https://togithub.com/FrozenPandaz)

### [`v18.1.1`](https://togithub.com/nrwl/nx/releases/tag/18.1.1)

[Compare Source](5e64e7dcb0...18.1.1)

##### 18.1.1 (2024-03-15)

##### 🚀 Features

-   **angular:** update jest-preset-angular version ([#&#8203;21776](https://togithub.com/nrwl/nx/pull/21776))
-   **angular:** add the extract-i18n executor ([#&#8203;21802](https://togithub.com/nrwl/nx/pull/21802))
-   **angular:** ensure all targets are generated for application and libraries ([#&#8203;21826](https://togithub.com/nrwl/nx/pull/21826))
-   **angular:** support angular 17.2.0 ([#&#8203;21671](https://togithub.com/nrwl/nx/pull/21671))
-   **angular:** force explicit targets when NX_ADD_PLUGINS is not explicitly true ([#&#8203;21852](https://togithub.com/nrwl/nx/pull/21852))
-   **angular:** update jest-preset-angular dependency to 14.0.3 ([#&#8203;21912](https://togithub.com/nrwl/nx/pull/21912))
-   **angular:** remove optional [@&#8203;nx/cypress](https://togithub.com/nx/cypress) and [@&#8203;nx/jest](https://togithub.com/nx/jest) from dependencies ([#&#8203;22162](https://togithub.com/nrwl/nx/pull/22162))
-   **bundling:** bump rollup-plugin-typescript2 version ([#&#8203;20609](https://togithub.com/nrwl/nx/pull/20609))
-   **bundling:** rollup should support ESM config files ([#&#8203;21999](https://togithub.com/nrwl/nx/pull/21999))
-   **bundling:** crystalize rollup ([#&#8203;22045](https://togithub.com/nrwl/nx/pull/22045))
-   **core:** update swc/register ([#&#8203;21755](https://togithub.com/nrwl/nx/pull/21755))
-   **core:** add option to disable log grouping on CI ([#&#8203;21782](https://togithub.com/nrwl/nx/pull/21782))
-   **core:** remove leading arrow from output headlines ([#&#8203;21359](https://togithub.com/nrwl/nx/pull/21359))
-   **core:** remove leading arrow from output headlines" ([#&#8203;21800](https://togithub.com/nrwl/nx/pull/21800))
-   **core:** support migrating to canary versions of nx for testing ([#&#8203;21812](https://togithub.com/nrwl/nx/pull/21812))
-   **core:** flatten default base config to base ([#&#8203;19964](https://togithub.com/nrwl/nx/pull/19964))
-   **core:** execute plugins in isolated processes ([#&#8203;21760](https://togithub.com/nrwl/nx/pull/21760))
-   **core:** provide a hint when project.json has empty targets ([#&#8203;22028](https://togithub.com/nrwl/nx/pull/22028))
-   **core:** add gradle plugin ([#&#8203;21055](https://togithub.com/nrwl/nx/pull/21055))
-   **core:** use flag in nx.json for toggling crystal ([#&#8203;21980](https://togithub.com/nrwl/nx/pull/21980))
-   **core:** forward options for run command ([#&#8203;22064](https://togithub.com/nrwl/nx/pull/22064))
-   **core:** revert running plugins in isolation ([#&#8203;22246](https://togithub.com/nrwl/nx/pull/22246))
-   **core:** run commands directly ([#&#8203;21918](https://togithub.com/nrwl/nx/pull/21918))
-   **detox:** upgrade [@&#8203;config-plugins/detox](https://togithub.com/config-plugins/detox) to 7 ([#&#8203;21959](https://togithub.com/nrwl/nx/pull/21959))
-   **expo:** support cjs and mjs ([#&#8203;21408](https://togithub.com/nrwl/nx/pull/21408))
-   **graph:** add error boundary error page for project details ([#&#8203;22007](https://togithub.com/nrwl/nx/pull/22007))
-   **graph:** add spinner on the projects page ([#&#8203;22149](https://togithub.com/nrwl/nx/pull/22149))
-   **js:** replace publish script with nx release config ([#&#8203;21474](https://togithub.com/nrwl/nx/pull/21474))
-   **misc:** log message in nx init when detecting plugins ([#&#8203;21932](https://togithub.com/nrwl/nx/pull/21932))
-   **nextjs:** use global NX_GRAPH_CREATION in withNx plugin to guard against graph creation during create nodes ([#&#8203;22026](https://togithub.com/nrwl/nx/pull/22026))
-   **nuxt:** export storybook generator ([#&#8203;21969](https://togithub.com/nrwl/nx/pull/21969))
-   **nx-dev:** update launch conf timings ([a0e4cf747d](https://togithub.com/nrwl/nx/commit/a0e4cf747d))
-   **nx-dev:** update launch page link text ([#&#8203;21747](https://togithub.com/nrwl/nx/pull/21747))
-   **nx-dev:** update website header components ([#&#8203;21833](https://togithub.com/nrwl/nx/pull/21833))
-   **nx-dev:** change color for nx-agents & nx-ai buttons on home ([#&#8203;22142](https://togithub.com/nrwl/nx/pull/22142))
-   **react:** add tailwind as style prompt option for app gen ([#&#8203;21784](https://togithub.com/nrwl/nx/pull/21784))
-   **release:** prompt to create github release when no file changes ([#&#8203;21819](https://togithub.com/nrwl/nx/pull/21819))
-   **release:** interpolate workspaceRoot in changelog path ([#&#8203;22058](https://togithub.com/nrwl/nx/pull/22058))
-   **release:** add conventional commits configurability for version and changelog ([#&#8203;22004](https://togithub.com/nrwl/nx/pull/22004))
-   **remix:** add playwright option for e2eTestRunner ([#&#8203;21603](https://togithub.com/nrwl/nx/pull/21603))
-   **remix:** upgrade to latest remix 2.6.0 ([#&#8203;21843](https://togithub.com/nrwl/nx/pull/21843))
-   **remix:** use Remix CLI directly with Remix Crystal Plugin ([#&#8203;22234](https://togithub.com/nrwl/nx/pull/22234))
-   **remix:** support version 2.8.0 ([#&#8203;22326](https://togithub.com/nrwl/nx/pull/22326))
-   **remix:** add option to create-nx-workspace ([#&#8203;22334](https://togithub.com/nrwl/nx/pull/22334))
-   **repo:** use latest pnpm for CI runs ([#&#8203;22207](https://togithub.com/nrwl/nx/pull/22207))
-   **testing:** update cypress version ([#&#8203;21961](https://togithub.com/nrwl/nx/pull/21961))
-   **testing:** add getJestProjectsAsync to support inferred targets ([#&#8203;21897](https://togithub.com/nrwl/nx/pull/21897))
-   **vite:** add vitest.workspace.ts at root ([#&#8203;21915](https://togithub.com/nrwl/nx/pull/21915))

##### 🩹 Fixes

-   **angular:** fix wrong trailing comma in mf bootstrap code generation ([#&#8203;21600](https://togithub.com/nrwl/nx/pull/21600))
-   **angular:** support inferred cypress targets in setup-mf generator ([#&#8203;21619](https://togithub.com/nrwl/nx/pull/21619))
-   **angular:** ajv hoisting issue ([#&#8203;21641](https://togithub.com/nrwl/nx/pull/21641))
-   **angular:** resolve the index html transformer correctly for esbuild based build targets in dev-server ([#&#8203;21679](https://togithub.com/nrwl/nx/pull/21679))
-   **angular:** generate app server module setup correctly in setup-ssr generator ([#&#8203;21702](https://togithub.com/nrwl/nx/pull/21702))
-   **angular:** add missing forceEsbuild option to dev-server executor ([#&#8203;21753](https://togithub.com/nrwl/nx/pull/21753))
-   **angular:** do not force explicit targets for separate e2e projects ([#&#8203;21865](https://togithub.com/nrwl/nx/pull/21865))
-   **angular:** stop using npmScope as a prefix for component and directive selectors ([#&#8203;21828](https://togithub.com/nrwl/nx/pull/21828))
-   **angular:** do not add target defaults for the ng-packagr-lite executor when generating non-buildable library ([#&#8203;21935](https://togithub.com/nrwl/nx/pull/21935))
-   **angular:** ensure generated editor tsconfig in apps only include runtime files ([#&#8203;21945](https://togithub.com/nrwl/nx/pull/21945))
-   **angular:** log message about unsupported ng cache command ([#&#8203;22154](https://togithub.com/nrwl/nx/pull/22154))
-   **angular:** fix message logged for unsupported ng cache ([#&#8203;22211](https://togithub.com/nrwl/nx/pull/22211))
-   **angular:** Module federation with Crystal enabled. ([#&#8203;22224](https://togithub.com/nrwl/nx/pull/22224))
-   **angular:** install jsonc-eslint-parser only when [@&#8203;nx/dependency-checks](https://togithub.com/nx/dependency-checks) is used ([#&#8203;22231](https://togithub.com/nrwl/nx/pull/22231))
-   **core:** nx cloud prompt during migrate doesn't skip connection ([#&#8203;21588](https://togithub.com/nrwl/nx/pull/21588))
-   **core:** pass the full resolved path of ts-node/esm when reloading the CLI ([#&#8203;21607](https://togithub.com/nrwl/nx/pull/21607))
-   **core:** remove logic to reload process with esm loader for Node 18 ([#&#8203;21623](https://togithub.com/nrwl/nx/pull/21623))
-   **core:** prevent target defaults from being discarded during merge process ([#&#8203;21624](https://togithub.com/nrwl/nx/pull/21624))
-   **core:** add missing parts to ci workflws and update docs ([ab76d6291a](https://togithub.com/nrwl/nx/commit/ab76d6291a))
-   **core:** temporary use forked portable_pty to inherit cursor position for windows ([#&#8203;21683](https://togithub.com/nrwl/nx/pull/21683))
-   **core:** handle blocking stdin ([#&#8203;21672](https://togithub.com/nrwl/nx/pull/21672))
-   **core:** remove implementation detail from warning ([18efd62003](https://togithub.com/nrwl/nx/commit/18efd62003))
-   **core:** static run one lifecycle should always print dependent task status, and output when verbose ([#&#8203;21720](https://togithub.com/nrwl/nx/pull/21720))
-   **core:** run migrations ordered by their target version ([#&#8203;21799](https://togithub.com/nrwl/nx/pull/21799))
-   **core:** Update NxWelcome connect to cloud ([#&#8203;21830](https://togithub.com/nrwl/nx/pull/21830))
-   **core:** propagate `verbose` flag when running `init` generator dur… ([#&#8203;21868](https://togithub.com/nrwl/nx/pull/21868))
-   **core:** ensure migrate works with yarn PnP ([#&#8203;21824](https://togithub.com/nrwl/nx/pull/21824))
-   **core:** align terminal output padding and remove leading arrow ([#&#8203;21809](https://togithub.com/nrwl/nx/pull/21809))
-   **core:** read all targets from package json when defining target defaults ([#&#8203;21719](https://togithub.com/nrwl/nx/pull/21719))
-   **core:** include nx/nuxt in migrations ([#&#8203;21885](https://togithub.com/nrwl/nx/pull/21885))
-   **core:** do not use the new pty function for older versions of windows ([#&#8203;21854](https://togithub.com/nrwl/nx/pull/21854))
-   **core:** normalize migration target versions when sorting migrations ([#&#8203;21967](https://togithub.com/nrwl/nx/pull/21967))
-   **core:** target defaults application shouldn't include extra scripts ([#&#8203;21970](https://togithub.com/nrwl/nx/pull/21970))
-   **core:** update generated README pages with more useful instructions ([#&#8203;21976](https://togithub.com/nrwl/nx/pull/21976))
-   **core:** plugin pool should not clobber promises when called multiple times ([#&#8203;21977](https://togithub.com/nrwl/nx/pull/21977))
-   **core:** plugins should not be registered twice and should respect shutdown queue ([#&#8203;22057](https://togithub.com/nrwl/nx/pull/22057))
-   **core:** nextjs-standalone generates package scripts consistent with create-next-app ([#&#8203;21996](https://togithub.com/nrwl/nx/pull/21996))
-   **core:** target defaults should represent nx.json in source info ([#&#8203;22080](https://togithub.com/nrwl/nx/pull/22080))
-   **core:** setting up .nx inside gradle shouldn't throw ([#&#8203;21957](https://togithub.com/nrwl/nx/pull/21957))
-   **core:** add outputs to nx.json for nx init in monorepo ([#&#8203;22061](https://togithub.com/nrwl/nx/pull/22061))
-   **core:** fix no such file or directory, open 'package-lock.json' ([#&#8203;21835](https://togithub.com/nrwl/nx/pull/21835))
-   **core:** reject all promises in pool during shutdown ([#&#8203;22188](https://togithub.com/nrwl/nx/pull/22188))
-   **core:** fix terminal message alignment on errors ([#&#8203;22189](https://togithub.com/nrwl/nx/pull/22189))
-   **core:** only start plugin workers once ([#&#8203;22222](https://togithub.com/nrwl/nx/pull/22222))
-   **core:** properly cleanup when the project-graph creation fails ([#&#8203;22243](https://togithub.com/nrwl/nx/pull/22243))
-   **core:** make windows runtime input hashing windowless ([#&#8203;22197](https://togithub.com/nrwl/nx/pull/22197))
-   **core:** fix gh group success icon ([#&#8203;22281](https://togithub.com/nrwl/nx/pull/22281))
-   **core:** fix pty for multiple commands in 1 process ([#&#8203;22294](https://togithub.com/nrwl/nx/pull/22294))
-   **devkit:** respect expectComments when parsing json ([#&#8203;21584](https://togithub.com/nrwl/nx/pull/21584))
-   **graph:** fix open project with / in name ([#&#8203;21722](https://togithub.com/nrwl/nx/pull/21722))
-   **graph:** show command property as monospace ([#&#8203;21997](https://togithub.com/nrwl/nx/pull/21997))
-   **js:** babel preset should also check for JEST_WORKER_ID to transpile to CJS ([#&#8203;21754](https://togithub.com/nrwl/nx/pull/21754))
-   **js:** nx release-version resolve-version-spec should normalize fetchSpec ([#&#8203;21710](https://togithub.com/nrwl/nx/pull/21710))
-   **js:** swc executor should support inlining on windows ([#&#8203;21801](https://togithub.com/nrwl/nx/pull/21801))
-   **js:** set moduleResolution to Node10 so it is compatible with CommonJS module ([#&#8203;21979](https://togithub.com/nrwl/nx/pull/21979))
-   **js:** use NodeJs moduleResolution with ts-node to support CommonJS module and TS 4.x ([#&#8203;22258](https://togithub.com/nrwl/nx/pull/22258))
-   **linter:** adjust terminal run check for crystal ([#&#8203;21638](https://togithub.com/nrwl/nx/pull/21638))
-   **linter:** fix eslint-plugin migration target version ([#&#8203;21966](https://togithub.com/nrwl/nx/pull/21966))
-   **linter:** add v7 of typescript-eslint to peerDeps ([#&#8203;21853](https://togithub.com/nrwl/nx/pull/21853))
-   **linter:** refactor pcv3 plugin, expose configFiles on context ([#&#8203;21677](https://togithub.com/nrwl/nx/pull/21677))
-   **misc:** handle workspaces if no plugin selected in nx init and only generate files after prompts ([#&#8203;21606](https://togithub.com/nrwl/nx/pull/21606))
-   **misc:** ensure swc transpiler process required files ([#&#8203;21674](https://togithub.com/nrwl/nx/pull/21674))
-   **misc:** pin generated vite version to ~5.0.0 to avoid issues with storybook ([#&#8203;21740](https://togithub.com/nrwl/nx/pull/21740))
-   **misc:** logs from rm-default-collection should render properly ([#&#8203;21953](https://togithub.com/nrwl/nx/pull/21953))
-   **misc:** set nx property in root package.json when no replacing script in nx init ([#&#8203;21974](https://togithub.com/nrwl/nx/pull/21974))
-   **misc:** migration should shutdown plugin workers if it starts them ([#&#8203;22048](https://togithub.com/nrwl/nx/pull/22048))
-   **misc:** make sure to add e2e crystal plugin ([#&#8203;22041](https://togithub.com/nrwl/nx/pull/22041))
-   **misc:** fix buildable libs utils calculating dependent projects from task graph ([#&#8203;22015](https://togithub.com/nrwl/nx/pull/22015))
-   **misc:** add missing format files call ([#&#8203;22137](https://togithub.com/nrwl/nx/pull/22137))
-   **misc:** improve package.json scripts handling when running "nx init" and "nx add" ([#&#8203;22168](https://togithub.com/nrwl/nx/pull/22168))
-   **misc:** do not add includedScripts unless really needed when running nx add ([#&#8203;22180](https://togithub.com/nrwl/nx/pull/22180))
-   **module-federation:** map static remote locations correctly ([#&#8203;21709](https://togithub.com/nrwl/nx/pull/21709))
-   **module-federation:** ensure targetDefaults for module federation executors are setup correctly ([#&#8203;22282](https://togithub.com/nrwl/nx/pull/22282))
-   **nextjs:** move `next/constants` from top-level import to when it is needed ([#&#8203;21612](https://togithub.com/nrwl/nx/pull/21612))
-   **nextjs:** Enable next e2e test ([#&#8203;21625](https://togithub.com/nrwl/nx/pull/21625))
-   **nextjs:** src package.json should not be copied to output folder ([aa622bab5a](https://togithub.com/nrwl/nx/commit/aa622bab5a))
-   **nextjs:** Custom server should work with Crystal ([#&#8203;21736](https://togithub.com/nrwl/nx/pull/21736))
-   **nextjs:** Svg should work when svgr is true in next config ([#&#8203;21761](https://togithub.com/nrwl/nx/pull/21761))
-   **nextjs:** Add missing e2e-ci target for cypress ([#&#8203;21805](https://togithub.com/nrwl/nx/pull/21805))
-   **nextjs:** Add spec files when creating a next app ([#&#8203;22079](https://togithub.com/nrwl/nx/pull/22079))
-   **nextjs:** avoid path error on dev  server creation ([#&#8203;21998](https://togithub.com/nrwl/nx/pull/21998))
-   **nextjs:** Adding styles to nextjs cypress should not fail. ([#&#8203;22170](https://togithub.com/nrwl/nx/pull/22170))
-   **nextjs:** Surface error codes when build is interrupted by signals SIGINT, SIGTERM etc... ([#&#8203;22190](https://togithub.com/nrwl/nx/pull/22190))
-   **nextjs:** runCLI stdio ([#&#8203;22267](https://togithub.com/nrwl/nx/pull/22267))
-   **node:** Broken E2E tests ([#&#8203;21569](https://togithub.com/nrwl/nx/pull/21569))
-   **node:** Increase timeout for CI ([#&#8203;22003](https://togithub.com/nrwl/nx/pull/22003))
-   **nuxt:** init generator should add [@&#8203;nx/vite](https://togithub.com/nx/vite) to dependencies ([#&#8203;21911](https://togithub.com/nrwl/nx/pull/21911))
-   **nuxt:** turn on autoimport ([#&#8203;21894](https://togithub.com/nrwl/nx/pull/21894))
-   **nuxt:** tsconfig types and output dir ([#&#8203;21934](https://togithub.com/nrwl/nx/pull/21934))
-   **nuxt:** fix storybook preview config path ([#&#8203;22020](https://togithub.com/nrwl/nx/pull/22020))
-   **nuxt:** Add e2e-ci and serve-static targets ([#&#8203;22056](https://togithub.com/nrwl/nx/pull/22056))
-   **nx-dev:** redirect core-features page ([#&#8203;21616](https://togithub.com/nrwl/nx/pull/21616))
-   **nx-dev:** launch page mobile experience ([de676e207f](https://togithub.com/nrwl/nx/commit/de676e207f))
-   **nx-dev:** redirect on remote caching page ([#&#8203;21669](https://togithub.com/nrwl/nx/pull/21669))
-   **nx-dev:** remove fence from new packages and "nx add" commands ([#&#8203;21705](https://togithub.com/nrwl/nx/pull/21705))
-   **nx-dev:** add colors to ms logo ([#&#8203;21790](https://togithub.com/nrwl/nx/pull/21790))
-   **nx-plugin:** do not print duplicated warning about derived format when generating plugin ([#&#8203;22230](https://togithub.com/nrwl/nx/pull/22230))
-   **nx-plugin:** support root tsconfig.json in nx-plugin-checks eslint rule ([4850bdb6aa](https://togithub.com/nrwl/nx/commit/4850bdb6aa))
-   **playwright:** fix include in tsconfig.json ([#&#8203;21730](https://togithub.com/nrwl/nx/pull/21730))
-   **react:** generate correctly when --js is used for module federation host/remote ([#&#8203;20119](https://togithub.com/nrwl/nx/pull/20119))
-   **react:** full support custom secure host for module federation ([#&#8203;21777](https://togithub.com/nrwl/nx/pull/21777))
-   **react:** ensure playwright configuration is using correct port in app gen ([#&#8203;21941](https://togithub.com/nrwl/nx/pull/21941))
-   **react:** pass correct argument to rspack configuration generator ([#&#8203;22241](https://togithub.com/nrwl/nx/pull/22241))
-   **react-native:** change gradlew to absolute path ([#&#8203;21725](https://togithub.com/nrwl/nx/pull/21725))
-   **react-native:** add all flag to sync-deps ([#&#8203;21821](https://togithub.com/nrwl/nx/pull/21821))
-   **react-native:** pin ajv version to 8.12.0 ([#&#8203;22002](https://togithub.com/nrwl/nx/pull/22002))
-   **release:** logging improvements ([#&#8203;21692](https://togithub.com/nrwl/nx/pull/21692))
-   **release:** ensure `nx release publish --graph` only includes projects with target ([#&#8203;21726](https://togithub.com/nrwl/nx/pull/21726))
-   **release:** do not stop daemon in dry-run ([#&#8203;21743](https://togithub.com/nrwl/nx/pull/21743))
-   **release:** skip prompt for publish when no version created ([#&#8203;21769](https://togithub.com/nrwl/nx/pull/21769))
-   **release:** use --first-parent to support merged repos ([#&#8203;21686](https://togithub.com/nrwl/nx/pull/21686))
-   **release:** move github release creation to git tasks ([#&#8203;21510](https://togithub.com/nrwl/nx/pull/21510))
-   **release:** currentVersionResolver git-tag should prefer merged tags ([#&#8203;22082](https://togithub.com/nrwl/nx/pull/22082))
-   **release:** skip lock file update if workspaces are not enabled ([#&#8203;22055](https://togithub.com/nrwl/nx/pull/22055))
-   **release:** store rawVersionSpec on versionData ([#&#8203;22071](https://togithub.com/nrwl/nx/pull/22071))
-   **release:** fix default renderer resolution to be relative within t… ([#&#8203;22331](https://togithub.com/nrwl/nx/pull/22331))
-   **remix:** do not rename root jest.preset.js ([#&#8203;21703](https://togithub.com/nrwl/nx/pull/21703))
-   **remix:** should add remix plugin to nx.json on init correctly ([#&#8203;21827](https://togithub.com/nrwl/nx/pull/21827))
-   **remix:** the output path should respect the remix.config.js in crystal ([#&#8203;21842](https://togithub.com/nrwl/nx/pull/21842))
-   **remix:** adjust remix start script when building ([#&#8203;21883](https://togithub.com/nrwl/nx/pull/21883))
-   **remix:** typo in tsconfig.spec.json update led to invalid tsconfig ([#&#8203;21886](https://togithub.com/nrwl/nx/pull/21886))
-   **remix:** ensure component-testing is exported correctly [#&#8203;22091](https://togithub.com/nrwl/nx/issues/22091) ([#&#8203;22095](https://togithub.com/nrwl/nx/pull/22095), [#&#8203;22091](https://togithub.com/nrwl/nx/issues/22091))
-   **repo:** update browser tools to fix ci ([#&#8203;21955](https://togithub.com/nrwl/nx/pull/21955))
-   **storybook:** handle main.js file correctly in storybook plugin ([#&#8203;22081](https://togithub.com/nrwl/nx/pull/22081))
-   **testing:** cleanup e2e atomization plugins ([#&#8203;21688](https://togithub.com/nrwl/nx/pull/21688))
-   **testing:** increase the default timeout to 15s for the dev server to start ([#&#8203;21716](https://togithub.com/nrwl/nx/pull/21716))
-   **testing:** ensure cypress closes the web dev server ([#&#8203;21759](https://togithub.com/nrwl/nx/pull/21759))
-   **testing:** jest should handle root jest.preset.cjs ([#&#8203;21746](https://togithub.com/nrwl/nx/pull/21746))
-   **testing:** fix cypress project targets does not exist ([#&#8203;21785](https://togithub.com/nrwl/nx/pull/21785))
-   **testing:** pin cypress version to avoid issue with verifying cypress ([#&#8203;21917](https://togithub.com/nrwl/nx/pull/21917))
-   **testing:** ensure baseUrl is not passed to playwright cli ([#&#8203;21943](https://togithub.com/nrwl/nx/pull/21943))
-   **testing:** playwright plugin enoent error ([#&#8203;21951](https://togithub.com/nrwl/nx/pull/21951))
-   **testing:** add null checks when reading targets ([#&#8203;21952](https://togithub.com/nrwl/nx/pull/21952))
-   **testing:** calculate correct support file path in cypress e2e preset ([#&#8203;22096](https://togithub.com/nrwl/nx/pull/22096))
-   **testing:** increase the default timeout to 60s for the cypress web dev server to start ([#&#8203;22132](https://togithub.com/nrwl/nx/pull/22132))
-   **testing:** close cypress web server correctly on windows ([#&#8203;22125](https://togithub.com/nrwl/nx/pull/22125))
-   **testing:** resolve cypress config glob pattern correctly to handle root projects ([#&#8203;22165](https://togithub.com/nrwl/nx/pull/22165))
-   **testing:** minor adjustment to the config generation template ([#&#8203;22175](https://togithub.com/nrwl/nx/pull/22175))
-   **testing:** fix project config might not be defined ([#&#8203;22174](https://togithub.com/nrwl/nx/pull/22174))
-   **vite:** import esbuild before loading config to keep it in cache ([#&#8203;21685](https://togithub.com/nrwl/nx/pull/21685))
-   **vite:** normalize vitest cli args in executor ([#&#8203;21870](https://togithub.com/nrwl/nx/pull/21870))
-   **vite:** project conversion generator ([#&#8203;21646](https://togithub.com/nrwl/nx/pull/21646))
-   **vite:** update vitest and use parseCLI ([#&#8203;21890](https://togithub.com/nrwl/nx/pull/21890))
-   **vite:** Storing nxjson details too early ([#&#8203;22285](https://togithub.com/nrwl/nx/pull/22285))
-   **vue:** fixing vue and nuxt welcome templates ([#&#8203;21792](https://togithub.com/nrwl/nx/pull/21792))
-   **vue:** tailwind generator ignoring styleSheet option ([#&#8203;21840](https://togithub.com/nrwl/nx/pull/21840))
-   **vue:** small typo in CNW description ([#&#8203;21888](https://togithub.com/nrwl/nx/pull/21888))
-   **webpack:** require ForkTsCheckerWebpackPlugin only as required ([#&#8203;21629](https://togithub.com/nrwl/nx/pull/21629))
-   **webpack:** resolve relative path for assets inputs ([#&#8203;21822](https://togithub.com/nrwl/nx/pull/21822))
-   **webpack:** correctly handle paranthesis in PostCSS in url ([#&#8203;21884](https://togithub.com/nrwl/nx/pull/21884))
-   **webpack:** surface original error when remotes fail to start ([#&#8203;21919](https://togithub.com/nrwl/nx/pull/21919))

##### ❤️  Thank You

-   Alex Swindler
-   Alon Valadji [@&#8203;alonronin](https://togithub.com/alonronin)
-   Austin Fahsl [@&#8203;fahslaj](https://togithub.com/fahslaj)
-   Benjamin Cabanes [@&#8203;bcabanes](https://togithub.com/bcabanes)
-   Colum Ferry [@&#8203;Coly010](https://togithub.com/Coly010)
-   Craigory Coppola [@&#8203;AgentEnder](https://togithub.com/AgentEnder)
-   Dan Roujinsky
-   Edouard Bozon [@&#8203;edbzn](https://togithub.com/edbzn)
-   Emily Xiong [@&#8203;xiongemi](https://togithub.com/xiongemi)
-   Jack Hsu [@&#8203;jaysoo](https://togithub.com/jaysoo)
-   James Henry [@&#8203;JamesHenry](https://togithub.com/JamesHenry)
-   Jason Jean [@&#8203;FrozenPandaz](https://togithub.com/FrozenPandaz)
-   Javier Abia [@&#8203;weberjavi](https://togithub.com/weberjavi)
-   Jonathan Cammisuli
-   Julian Martin
-   Juri [@&#8203;juristr](https://togithub.com/juristr)
-   Juri Strumpflohner [@&#8203;juristr](https://togithub.com/juristr)
-   Katerina Skroumpelou [@&#8203;mandarini](https://togithub.com/mandarini)
-   Leosvel Pérez Espinosa [@&#8203;leosvelperez](https://togithub.com/leosvelperez)
-   MaxKless [@&#8203;MaxKless](https://togithub.com/MaxKless)
-   Miroslav Jonas [@&#8203;meeroslav](https://togithub.com/meeroslav)
-   Miroslav Jonaš [@&#8203;meeroslav](https://togithub.com/meeroslav)
-   Nicholas Cunningham [@&#8203;ndcunningham](https://togithub.com/ndcunningham)
-   Nikita Barsukov [@&#8203;nsbarsukov](https://togithub.com/nsbarsukov)
-   Philip Fulcher
-   Remco Krams
-   Steven Nance [@&#8203;llwt](https://togithub.com/llwt)
-   Tine Kondo [@&#8203;tinesoft](https://togithub.com/tinesoft)
-   Vadim Goy
-   Victor Login [@&#8203;batazor](https://togithub.com/batazor)
-   Viktor Pöntinen
-   Yu Zheng
-   Zachary DeRose [@&#8203;ZackDeRose](https://togithub.com/ZackDeRose)

### [`v18.1.0`](https://togithub.com/nrwl/nx/compare/18.0.8...5e64e7dcb011fd164e22f87a9f2a6358a7c2cd32)

[Compare Source](https://togithub.com/nrwl/nx/compare/18.0.8...5e64e7dcb011fd164e22f87a9f2a6358a7c2cd32)

</details>

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-istanbul)</summary>

### [`v1.4.0`](https://togithub.com/vitest-dev/vitest/releases/tag/v1.4.0)

[Compare Source](https://togithub.com/vitest-dev/vitest/compare/v1.3.1...v1.4.0)

#####    🚀 Features

-   Throw error when using snapshot assertion with `not`  -  by [@&#8203;fenghan34](https://togithub.com/fenghan34) in [https://github.com/vitest-dev/vitest/issues/5294](https://togithub.com/vitest-dev/vitest/issues/5294) [<samp>(b9d37)</samp>](https://togithub.com/vitest-dev/vitest/commit/b9d378f5)
-   Add a flag to include test location in tasks  -  by [@&#8203;sheremet-va](https://togithub.com/sheremet-va) in [https://github.com/vitest-dev/vitest/issues/5342](https://togithub.com/vitest-dev/vitest/issues/5342) [<samp>(d627e)</samp>](https://togithub.com/vitest-dev/vitest/commit/d627e209)
-   **cli**:
    -   Support wildcards in `--project` option  -  by [@&#8203;fenghan34](https://togithub.com/fenghan34) in [https://github.com/vitest-dev/vitest/issues/5295](https://togithub.com/vitest-dev/vitest/issues/5295) [<samp>(201bd)</samp>](https://togithub.com/vitest-dev/vitest/commit/201bd067)
-   **config**:
    -   Add `shuffle.files` and `shuffle.tests` options  -  by [@&#8203;fenghan34](https://togithub.com/fenghan34) in [https://github.com/vitest-dev/vitest/issues/5281](https://togithub.com/vitest-dev/vitest/issues/5281) [<samp>(356db)</samp>](https://togithub.com/vitest-dev/vitest/commit/356db87b)
    -   Deprecate `cache.dir` option  -  by [@&#8203;fenghan34](https://togithub.com/fenghan34) in [https://github.com/vitest-dev/vitest/issues/5229](https://togithub.com/vitest-dev/vitest/issues/5229) [<samp>(d7e8b)</samp>](https://togithub.com/vitest-dev/vitest/commit/d7e8b53e)
-   **coverage**:
    -   Support `--changed` option  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5314](https://togithub.com/vitest-dev/vitest/issues/5314) [<samp>(600b4)</samp>](https://togithub.com/vitest-dev/vitest/commit/600b44d6)
-   **vitest**:
    -   Support `clearScreen` cli flag  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5241](https://togithub.com/vitest-dev/vitest/issues/5241) [<samp>(e1735)</samp>](https://togithub.com/vitest-dev/vitest/commit/e1735fb6)

#####    🐞 Bug Fixes

-   Repeatable `--project` option  -  by [@&#8203;fenghan34](https://togithub.com/fenghan34) in [https://github.com/vitest-dev/vitest/issues/5265](https://togithub.com/vitest-dev/vitest/issues/5265) [<samp>(d1a06)</samp>](https://togithub.com/vitest-dev/vitest/commit/d1a06730)
-   `--inspect-brk` to pause before execution  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5355](https://togithub.com/vitest-dev/vitest/issues/5355) [<samp>(e77c5)</samp>](https://togithub.com/vitest-dev/vitest/commit/e77c553f)
-   Correct locations in test.each tasks  -  by [@&#8203;sheremet-va](https://togithub.com/sheremet-va) [<samp>(4f6e3)</samp>](https://togithub.com/vitest-dev/vitest/commit/4f6e39c1)
-   **api**:
    -   Use resolvedUrls from devserver  -  by [@&#8203;saitonakamura](https://togithub.com/saitonakamura) and [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5289](https://togithub.com/vitest-dev/vitest/issues/5289) [<samp>(2fef5)</samp>](https://togithub.com/vitest-dev/vitest/commit/2fef5a7e)
-   **browser**:
    -   Add `magic-string` to `optimizeDeps.include`  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5278](https://togithub.com/vitest-dev/vitest/issues/5278) [<samp>(8f04e)</samp>](https://togithub.com/vitest-dev/vitest/commit/8f04e798)
-   **coverage**:
    -   Expensive regexp hangs v8 report generation  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5259](https://togithub.com/vitest-dev/vitest/issues/5259) [<samp>(d68a7)</samp>](https://togithub.com/vitest-dev/vitest/commit/d68a7390)
    -   V8 to ignore type-only files  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5328](https://togithub.com/vitest-dev/vitest/issues/5328) [<samp>(c3eb8)</samp>](https://togithub.com/vitest-dev/vitest/commit/c3eb8deb)
    -   Respect source maps of pre-transpiled sources  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5367](https://togithub.com/vitest-dev/vitest/issues/5367) [<samp>(6eda4)</samp>](https://togithub.com/vitest-dev/vitest/commit/6eda473f)
    -   Prevent `reportsDirectory` from removing user's project  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5376](https://togithub.com/vitest-dev/vitest/issues/5376) [<samp>(07ec3)</samp>](https://togithub.com/vitest-dev/vitest/commit/07ec3779)
-   **expect**:
    -   Show diff on `toContain/toMatch` assertion error  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5267](https://togithub.com/vitest-dev/vitest/issues/5267) [<samp>(8ee59)</samp>](https://togithub.com/vitest-dev/vitest/commit/8ee59f0d)
-   **forks**:
    -   Wrap `defines` to support `undefined` values  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5284](https://togithub.com/vitest-dev/vitest/issues/5284) [<samp>(5b58b)</samp>](https://togithub.com/vitest-dev/vitest/commit/5b58b399)
-   **typecheck**:
    -   Update get-tsconfig 4.7.3 to fix false circularity error  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5384](https://togithub.com/vitest-dev/vitest/issues/5384) [<samp>(bdc37)</samp>](https://togithub.com/vitest-dev/vitest/commit/bdc371ee)
-   **ui**:
    -   Escape html in error diff  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5325](https://togithub.com/vitest-dev/vitest/issues/5325) [<samp>(ab60b)</samp>](https://togithub.com/vitest-dev/vitest/commit/ab60bf8d)
-   **vitest**:
    -   Loosen `onConsoleLog` return type  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5337](https://togithub.com/vitest-dev/vitest/issues/5337) [<samp>(6d1b1)</samp>](https://togithub.com/vitest-dev/vitest/commit/6d1b1451)
    -   Ensure restoring terminal cursor on close  -  by [@&#8203;hi-ogawa](https://togithub.com/hi-ogawa) in [https://github.com/vitest-dev/vitest/issues/5292](https://togithub.com/vitest-dev/vitest/issues/5292) [<samp>(0bea2)</samp>](https://togithub.com/vitest-dev/vitest/commit/0bea2247)
    -   Ignore timeout on websocket reporter rpc  -  by [@&#8203;sheremet-va](https://togithub.com/sheremet-va) [<samp>(38119)</samp>](https://togithub.com/vitest-dev/vitest/commit/38119b75)
    -   Correctly override api with --no-api flag  -  by [@&#8203;sheremet-va](https://togithub.com/sheremet-va) in [https://github.com/vitest-dev/vitest/issues/5386](https://togithub.com/vitest-dev/vitest/issues/5386) [<samp>(51d1d)</samp>](https://togithub.com/vitest-dev/vitest/commit/51d1d472)
    -   Logs in `beforeAll` and `afterAll`  -  by [@&#8203;fenghan34](https://togithub.com/fenghan34) in [https://github.com/vitest-dev/vitest/issues/5288](https://togithub.com/vitest-dev/vitest/issues/5288) [<samp>(ce5ca)</samp>](https://togithub.com/vitest-dev/vitest/commit/ce5ca6bf)
-   **workspace**:
    -   Throw error when browser mode and `@vitest/coverage-v8` are used  -  by [@&#8203;AriPerkkio](https://togithub.com/AriPerkkio) in [https://github.com/vitest-dev/vitest/issues/5250](https://togithub.com/vitest-dev/vitest/issues/5250) [<samp>(29f98)</samp>](https://togithub.com/vitest-dev/vitest/commit/29f98cd3)

#####     [View changes on GitHub](https://togithub.com/vitest-dev/vitest/compare/v1.3.1...v1.4.0)

</details>

---

### Configuration

📅 **Schedule**: 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://togithub.com/renovatebot/renovate/discussions) if that's undesired.

---

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

---

This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yMzguMSIsInVwZGF0ZWRJblZlciI6IjM3LjI0NS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5In0=-->
2024-03-19 03:01:03 +00:00
liuyi
797e3c5b35 fix(server): do not force sign in password length (#6188)
hotfix
2024-03-19 10:58:34 +08:00
liuyi
bba1a95f9c chore: bump base version to 0.14.0 (#6170) 2024-03-19 02:34:18 +00:00
DarkSky
da32682afb fix(server): handle expired lock re-release & external locker injection (#6145) 2024-03-19 02:16:47 +00:00
liuyi
4702c1a9ca fix(server): inject correct locker to request scope mutex (#6140) 2024-03-19 02:16:35 +00:00
DarkSky
f18133af82 fix(server): wrap read-modify-write apis with distributed lock (#6142) 2024-03-19 02:16:24 +00:00
liuyi
a4cd8d6ca3 chore(server): organize server configs (#6169) 2024-03-19 02:05:56 +00:00
liuyi
a721b3887b fix(server): hotfix auth & doc push (#6168) 2024-03-18 16:32:35 +08:00
Peng Xiao
5693d90451 e2e(core): add test for split view (#6133) 2024-03-18 07:04:06 +00:00
Peng Xiao
dc8f351051 refactor(component): render react element into lit (#6124)
See docs in https://insider.affine.pro/share/af3478a2-9c9c-4d16-864d-bffa1eb10eb6/oL1ifjA4rKv7HRn5nYzIF

This PR also enables opening split view by ctrl-click a page link in a doc.
2024-03-18 07:04:02 +00:00
Peng Xiao
e896f19f1a fix(electron): disable mica for windows for now (#6165)
Upstream https://github.com/electron/electron/issues/41073
2024-03-18 06:52:40 +00:00
Peng Xiao
386bd033af fix(electron): add dedicated api for opening external links in the default browser (#6166) 2024-03-18 06:41:48 +00:00
Fangdun Tsai
8301d82548 fix(core): sync list titles in sidebar (#6157) 2024-03-18 14:39:28 +08:00
LongYinan
0b0b3e0ae9 build(deps): bump follow-redirects from 1.15.5 to 1.15.6 (#6164)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
<details>
<summary>Commits</summary>
<ul>
<li><a href="35a517c586"><code>35a517c</code></a> Release version 1.15.6 of the npm package.</li>
<li><a href="c4f847f851"><code>c4f847f</code></a> Drop Proxy-Authorization across hosts.</li>
<li><a href="8526b4a1b2"><code>8526b4a</code></a> Use GitHub for disclosure.</li>
<li>See full diff in <a href="https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6">compare view</a></li>
</ul>
</details>
<br />

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=follow-redirects&package-manager=npm_and_yarn&previous-version=1.15.5&new-version=1.15.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/toeverything/AFFiNE/network/alerts).

</details>
2024-03-18 03:53:11 +00:00
liuyi
268ca03f62 fix(server): ensure selfhost admin created after all data migrated (#6163)
fix #6154 

cp to canary
2024-03-18 11:43:12 +08:00
LongYinan
58c81dd1ac chore: bump up get-stream version to v9 (#6139)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [get-stream](https://togithub.com/sindresorhus/get-stream) | [`^8.0.1` -> `^9.0.0`](https://renovatebot.com/diffs/npm/get-stream/8.0.1/9.0.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/get-stream/9.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/get-stream/9.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/get-stream/8.0.1/9.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/get-stream/8.0.1/9.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>sindresorhus/get-stream (get-stream)</summary>

### [`v9.0.0`](https://togithub.com/sindresorhus/get-stream/releases/tag/v9.0.0)

[Compare Source](https://togithub.com/sindresorhus/get-stream/compare/v8.0.1...v9.0.0)

##### Breaking

-   Require Node.js 18 ([#&#8203;111](https://togithub.com/sindresorhus/get-stream/issues/111))  [`7ccab70`](https://togithub.com/sindresorhus/get-stream/commit/7ccab70)

##### Improvements

-   Fix browser support ([#&#8203;122](https://togithub.com/sindresorhus/get-stream/issues/122))  [`4d233d3`](https://togithub.com/sindresorhus/get-stream/commit/4d233d3)
-   Allow multiple readers at once ([#&#8203;121](https://togithub.com/sindresorhus/get-stream/issues/121))  [`a51d085`](https://togithub.com/sindresorhus/get-stream/commit/a51d085)

</details>

---

### Configuration

📅 **Schedule**: 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 has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yNDUuMCIsInVwZGF0ZWRJblZlciI6IjM3LjI0NS4wIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5In0=-->
2024-03-18 03:08:03 +00:00
liuyi
e94be8968b fix(server): hotfix (#6161) 2024-03-18 10:41:11 +08:00
Fangdun Tsai
636fa503b8 feat: support esc shortcut on input-edit (#6143) 2024-03-16 13:13:37 +00:00
Fangdun Tsai
eec24db1a1 fix: use the esc shortcut to exit in create collection dialog (#6138) 2024-03-16 19:18:06 +08:00
LongYinan
7ed86a66e8 build(core): add source-map-loader for blocksuite codes (#6137) 2024-03-15 09:37:48 +00:00
674 changed files with 13885 additions and 9172 deletions

View File

@@ -1,4 +1,4 @@
const { resolve } = require('node:path');
const { join } = require('node:path');
const createPattern = packageName => [
{
@@ -31,11 +31,6 @@ const createPattern = packageName => [
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',
importNames: ['mergeUpdates'],
},
{
group: ['@affine/env/constant'],
message:
@@ -93,16 +88,17 @@ const config = {
},
ecmaVersion: 'latest',
sourceType: 'module',
project: resolve(__dirname, './tsconfig.eslint.json'),
project: join(__dirname, 'tsconfig.eslint.json'),
},
plugins: [
'react',
'@typescript-eslint',
'simple-import-sort',
'sonarjs',
'i',
'import-x',
'unused-imports',
'unicorn',
'rxjs',
],
rules: {
'array-callback-return': 'error',
@@ -135,6 +131,7 @@ const config = {
'unused-imports/no-unused-imports': 'error',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'import-x/no-duplicates': 'error',
'@typescript-eslint/ban-ts-comment': [
'error',
{
@@ -168,11 +165,6 @@ const config = {
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',
importNames: ['mergeUpdates'],
},
],
},
],
@@ -212,6 +204,21 @@ const config = {
'sonarjs/no-collection-size-mischeck': 'error',
'sonarjs/no-useless-catch': 'error',
'sonarjs/no-identical-functions': 'error',
'rxjs/finnish': [
'error',
{
functions: false,
methods: false,
strict: true,
types: {
'^LiveData$': true,
// some yjs classes are Observables, but they don't need to be in Finnish notation
'^Doc$': false, // yjs Doc
'^Awareness$': false, // yjs Awareness
'^UndoManager$': false, // yjs UndoManager
},
},
],
},
overrides: [
{
@@ -228,9 +235,6 @@ const config = {
},
...allPackages.map(pkg => ({
files: [`${pkg}/src/**/*.ts`, `${pkg}/src/**/*.tsx`],
parserOptions: {
project: resolve(__dirname, './tsconfig.eslint.json'),
},
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
@@ -247,7 +251,7 @@ const config = {
],
'@typescript-eslint/no-misused-promises': ['error'],
'@typescript-eslint/prefer-readonly': 'error',
'i/no-extraneous-dependencies': ['error'],
'import-x/no-extraneous-dependencies': ['error'],
'react-hooks/exhaustive-deps': [
'warn',
{

View File

@@ -21,7 +21,6 @@ const {
AFFINE_GOOGLE_CLIENT_ID,
AFFINE_GOOGLE_CLIENT_SECRET,
CLOUD_SQL_IAM_ACCOUNT,
CLOUD_LOGGER_IAM_ACCOUNT,
GCLOUD_CONNECTION_NAME,
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT,
REDIS_HOST,
@@ -60,9 +59,7 @@ const createHelmCommand = ({ isDryRun }) => {
? [
`--set-json web.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json graphql.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json graphql.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_LOGGER_IAM_ACCOUNT}\\"}\"`,
`--set-json sync.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json sync.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_LOGGER_IAM_ACCOUNT}\\"}\"`,
`--set-json cloud-sql-proxy.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`,
`--set-json cloud-sql-proxy.nodeSelector=\"{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }\"`,
]
@@ -114,7 +111,7 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string graphql.app.oauth.google.clientSecret="${AFFINE_GOOGLE_CLIENT_SECRET}"`,
`--set-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`,
`--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_KEY}"`,
`--set graphql.app.experimental.enableJwstCodec=${isInternal}`,
`--set graphql.app.experimental.enableJwstCodec=${namespace === 'dev'}`,
`--set graphql.app.features.earlyAccessPreview=false`,
`--set graphql.app.features.syncClientVersionCheck=true`,
`--set sync.replicaCount=${syncReplicaCount}`,

View File

@@ -11,7 +11,7 @@ runs:
- name: Download tar.gz
uses: actions/download-artifact@v4
with:
name: core
name: web
path: .
- name: Extract core artifacts

View File

@@ -1,6 +1,6 @@
FROM openresty/openresty:1.25.3.1-0-buster
WORKDIR /app
COPY ./packages/frontend/core/dist ./dist
COPY ./packages/frontend/web/dist ./dist
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf

View File

@@ -1,7 +1,7 @@
FROM node:20-bookworm-slim
COPY ./packages/backend/server /app
COPY ./packages/frontend/core/dist /app/static
COPY ./packages/frontend/web/dist /app/static
WORKDIR /app
RUN apt-get update && \

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.12.0"
appVersion: "0.14.0"

View File

@@ -3,7 +3,7 @@ name: graphql
description: AFFiNE GraphQL server
type: application
version: 0.0.0
appVersion: "0.12.0"
appVersion: "0.14.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -61,18 +61,3 @@ Create the name of the service account to use
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{- define "jwt.key" -}}
{{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.app.jwt.secretName -}}
{{- if and $secret $secret.data.private -}}
{{/*
Reusing existing secret data
*/}}
key: {{ $secret.data.private }}
{{- else -}}
{{/*
Generate new data
*/}}
key: {{ genPrivateKey "ecdsa" | b64enc }}
{{- end -}}
{{- end -}}

View File

@@ -28,10 +28,10 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AUTH_PRIVATE_KEY
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.jwt.secretName }}"
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
@@ -45,8 +45,6 @@ spec:
value: "graphql"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: NEXTAUTH_URL
value: "{{ .Values.global.ingress.host }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:

View File

@@ -1,7 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.jwt.secretName }}"
type: Opaque
data:
{{- ( include "jwt.key" . ) | indent 2 -}}

View File

@@ -0,0 +1,18 @@
{{- $privateKey := default (genPrivateKey "ecdsa") .Values.global.secret.privateKey | b64enc | quote }}
{{- if not .Values.global.secret.privateKey }}
{{- $existingKey := (lookup "v1" "Secret" .Release.Namespace .Values.global.secret.secretName) }}
{{- if $existingKey }}
{{- $privateKey = index $existingKey.data "key" }}
{{- end -}}
{{- end -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.global.secret.secretName }}
annotations:
"helm.sh/resource-policy": "keep"
type: Opaque
data:
key: {{ $privateKey }}

View File

@@ -19,10 +19,6 @@ app:
https: true
doc:
mergeInterval: "3000"
jwt:
secretName: jwt-private-key
# base64 encoded ecdsa private key
privateKey: ''
captcha:
enable: false
secretName: captcha

View File

@@ -3,7 +3,7 @@ name: sync
description: AFFiNE Sync Server
type: application
version: 0.0.0
appVersion: "0.12.0"
appVersion: "0.14.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -32,6 +32,11 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NO_COLOR
@@ -40,8 +45,6 @@ spec:
value: "affine"
- name: SERVER_FLAVOR
value: "sync"
- name: NEXTAUTH_URL
value: "{{ .Values.global.ingress.host }}"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD

View File

@@ -12,7 +12,6 @@ env: 'production'
app:
# AFFINE_SERVER_HOST
host: '0.0.0.0'
serviceAccount:
create: true
annotations: {}

View File

@@ -4,6 +4,9 @@ global:
className: ''
host: affine.pro
tls: []
secret:
secretName: 'server-private-key'
privateKey: ''
database:
user: 'postgres'
url: 'pg-postgresql'

View File

@@ -266,8 +266,8 @@ jobs:
path: ./packages/backend/storage/storage.node
if-no-files-found: error
build-core:
name: Build @affine/core
build-web:
name: Build @affine/web
runs-on: ubuntu-latest
steps:
@@ -277,15 +277,15 @@ jobs:
with:
electron-install: false
full-cache: true
- name: Build Core
- name: Build Web
# always skip cache because its fast, and cache configuration is always changing
run: yarn nx build @affine/core --skip-nx-cache
- name: zip core
run: tar -czf dist.tar.gz --directory=packages/frontend/core/dist .
- name: Upload core artifact
run: yarn nx build @affine/web --skip-nx-cache
- name: zip web
run: tar -czf dist.tar.gz --directory=packages/frontend/web/dist .
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: core
name: web
path: dist.tar.gz
if-no-files-found: error
@@ -485,7 +485,7 @@ jobs:
test: true,
}
needs:
- build-core
- build-web
- build-native
steps:
- uses: actions/checkout@v4
@@ -516,8 +516,8 @@ jobs:
shell: bash
run: yarn workspace @affine/electron vitest
- name: Download core artifact
uses: ./.github/actions/download-core
- name: Download web artifact
uses: ./.github/actions/download-web
with:
path: packages/frontend/electron/resources/web-static

View File

@@ -15,6 +15,7 @@ on:
env:
APP_NAME: affine
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
jobs:
build-server:
@@ -38,8 +39,8 @@ jobs:
name: server-dist
path: ./packages/backend/server/dist
if-no-files-found: error
build-core:
name: Build @affine/core
build-web:
name: Build @affine/web
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
@@ -50,7 +51,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/core --skip-nx-cache
run: yarn nx build @affine/web --skip-nx-cache
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -64,15 +65,15 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
- name: Upload core artifact
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: core
path: ./packages/frontend/core/dist
name: web
path: ./packages/frontend/web/dist
if-no-files-found: error
build-core-selfhost:
name: Build @affine/core selfhost
build-web-selfhost:
name: Build @affine/web selfhost
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
@@ -83,7 +84,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/core --skip-nx-cache
run: yarn nx build @affine/web --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: false
@@ -91,11 +92,11 @@ jobs:
SELF_HOSTED: true
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload core artifact
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-core
path: ./packages/frontend/core/dist
name: selfhost-web
path: ./packages/frontend/web/dist
if-no-files-found: error
build-storage:
@@ -143,16 +144,16 @@ jobs:
packages: 'write'
needs:
- build-server
- build-core
- build-core-selfhost
- build-web
- build-web-selfhost
- build-storage
steps:
- uses: actions/checkout@v4
- name: Download core artifact
uses: actions/download-artifact@v4
with:
name: core
path: ./packages/frontend/core/dist
name: web
path: ./packages/frontend/web/dist
- name: Download server dist
uses: actions/download-artifact@v4
with:
@@ -218,14 +219,14 @@ jobs:
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
- name: Remove core dist
run: rm -rf ./packages/frontend/core/dist
- name: Remove web dist
run: rm -rf ./packages/frontend/web/dist
- name: Download selfhost core artifact
- name: Download selfhost web artifact
uses: actions/download-artifact@v4
with:
name: selfhost-core
path: ./packages/frontend/core/dist
name: selfhost-web
path: ./packages/frontend/web/dist
- name: Install Node.js dependencies
run: |
@@ -295,7 +296,6 @@ jobs:
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }}
CLOUD_LOGGER_IAM_ACCOUNT: ${{ secrets.CLOUD_LOGGER_IAM_ACCOUNT }}
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
STATIC_IP_NAME: ${{ secrets.STATIC_IP_NAME }}

View File

@@ -33,6 +33,7 @@ env:
DEBUG: napi:*
APP_NAME: affine
MACOSX_DEPLOYMENT_TARGET: '10.13'
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
jobs:
before-make:
@@ -60,10 +61,10 @@ jobs:
SKIP_PLUGIN_BUILD: 'true'
SKIP_NX_CACHE: 'true'
- name: Upload core artifact
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: core
name: web
path: packages/frontend/electron/resources/web-static
make-distribution:
@@ -110,7 +111,7 @@ jobs:
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- uses: actions/download-artifact@v4
with:
name: core
name: web
path: packages/frontend/electron/resources/web-static
- name: Build Desktop Layers
@@ -188,7 +189,7 @@ jobs:
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- uses: actions/download-artifact@v4
with:
name: core
name: web
path: packages/frontend/electron/resources/web-static
- name: Build Desktop Layers
@@ -317,7 +318,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: core
name: web
path: web-static
- name: Zip web-static
run: zip -r web-static.zip web-static

View File

@@ -55,20 +55,18 @@ When logging in via email, you will see the mail arriving at localhost:8025 in a
```
DATABASE_URL="postgresql://affine:affine@localhost:5432/affine"
NEXTAUTH_URL="http://localhost:8080"
MAILER_SENDER="noreply@toeverything.info"
MAILER_USER="auth"
MAILER_PASSWORD="auth"
MAILER_HOST="localhost"
MAILER_PORT="1025"
STRIPE_API_KEY=sk_live_1
STRIPE_WEBHOOK_KEY=1
```
## Prepare prisma
```
yarn workspace @affine/server prisma db push
yarn workspace @affine/server data-migration run
```
Note, you may need to do it again if db schema changed.

View File

@@ -19,5 +19,5 @@
],
"ext": "ts,md,json"
},
"version": "0.12.0"
"version": "0.14.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/monorepo",
"version": "0.12.0",
"version": "0.14.0",
"private": true,
"author": "toeverything",
"license": "MIT",
@@ -17,20 +17,20 @@
"node": "<21.0.0"
},
"scripts": {
"dev": "dev-core",
"dev": "yarn workspace @affine/cli dev",
"dev:electron": "yarn workspace @affine/electron dev",
"build": "yarn nx build @affine/core",
"build": "yarn nx build @affine/web",
"build:electron": "yarn nx build @affine/electron",
"build:storage": "yarn nx run-many -t build -p @affine/storage",
"build:storybook": "yarn nx build @affine/storybook",
"start:web-static": "yarn workspace @affine/core static-server",
"start:web-static": "yarn workspace @affine/web static-server",
"start:storybook": "yarn exec serve tests/storybook/storybook-static -l 6006",
"serve:test-static": "yarn exec serve tests/fixtures --cors -p 8081",
"lint:eslint": "eslint . --ext .js,mjs,.ts,.tsx --cache",
"lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --ext .js,mjs,.ts,.tsx --cache",
"lint:eslint:fix": "yarn lint:eslint --fix",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export -A no-unresolved -A no-default-export -A no-duplicates -A no-side-effects-in-initialization -A no-named-as-default -A getter-return",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
@@ -44,7 +44,7 @@
"*": "prettier --write --ignore-unknown --cache",
"*.{ts,tsx,mjs,js,jsx}": [
"prettier --ignore-unknown --write",
"eslint --cache --fix"
"cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --cache --fix"
],
"*.toml": [
"taplo format"
@@ -61,7 +61,7 @@
"@faker-js/faker": "^8.4.1",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.5.0",
"@nx/vite": "18.0.8",
"@nx/vite": "18.1.2",
"@playwright/test": "^1.41.2",
"@taplo/cli": "^0.7.0",
"@testing-library/react": "^14.2.1",
@@ -74,28 +74,30 @@
"@vanilla-extract/vite-plugin": "^4.0.4",
"@vanilla-extract/webpack-plugin": "^2.3.6",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-istanbul": "1.3.1",
"@vitest/ui": "1.3.1",
"@vitest/coverage-istanbul": "1.4.0",
"@vitest/ui": "1.4.0",
"cross-env": "^7.0.3",
"electron": "^29.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-i": "^2.29.1",
"eslint-plugin-import-x": "^0.4.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-sonarjs": "^0.24.0",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unused-imports": "^3.1.0",
"eslint-plugin-vue": "^9.22.0",
"fake-indexeddb": "5.0.2",
"happy-dom": "^13.4.1",
"happy-dom": "^14.0.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"msw": "^2.2.1",
"nanoid": "^5.0.6",
"nx": "^18.0.4",
"nyc": "^15.1.0",
"oxlint": "0.0.22",
"oxlint": "0.2.14",
"prettier": "^3.2.5",
"semver": "^7.6.0",
"serve": "^14.2.1",
@@ -105,7 +107,7 @@
"vite": "^5.1.4",
"vite-plugin-istanbul": "^6.0.0",
"vite-plugin-static-copy": "^1.0.1",
"vitest": "1.3.1",
"vitest": "1.4.0",
"vitest-fetch-mock": "^0.2.2",
"vitest-mock-extended": "^1.3.1"
},
@@ -168,7 +170,7 @@
"which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest",
"which-typed-array": "npm:@nolyfill/which-typed-array@latest",
"@reforged/maker-appimage/@electron-forge/maker-base": "7.3.0",
"macos-alias": "npm:@napi-rs/macos-alias@latest",
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
"fs-xattr": "npm:@napi-rs/xattr@latest",
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
}

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[user_id,plan]` on the table `user_subscriptions` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "user_subscriptions_user_id_key";
-- CreateIndex
CREATE UNIQUE INDEX "user_subscriptions_user_id_plan_key" ON "user_subscriptions"("user_id", "plan");

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.12.0",
"version": "0.14.0",
"description": "Affine Node.js server",
"type": "module",
"bin": {
@@ -20,7 +20,7 @@
"dependencies": {
"@apollo/server": "^4.10.0",
"@auth/prisma-adapter": "^1.4.0",
"@aws-sdk/client-s3": "^3.515.0",
"@aws-sdk/client-s3": "^3.536.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@google-cloud/opentelemetry-resource-util": "^2.1.0",
@@ -63,7 +63,7 @@
"dotenv-cli": "^7.3.0",
"express": "^4.18.2",
"file-type": "^19.0.0",
"get-stream": "^8.0.1",
"get-stream": "^9.0.0",
"graphql": "^16.8.1",
"graphql-scalars": "^1.22.4",
"graphql-type-json": "^0.3.2",
@@ -71,6 +71,7 @@
"ioredis": "^5.3.2",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"mixpanel": "^0.18.0",
"nanoid": "^5.0.6",
"nest-commander": "^3.12.5",
"nestjs-throttler-storage-redis": "^0.4.1",
@@ -102,6 +103,7 @@
"@types/graphql-upload": "^16.0.7",
"@types/keyv": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/mixpanel": "^2.14.8",
"@types/node": "^20.11.20",
"@types/nodemailer": "^6.4.14",
"@types/on-headers": "^1.0.3",

View File

@@ -24,7 +24,7 @@ model User {
features UserFeatures[]
customer UserStripeCustomer?
subscription UserSubscription?
subscriptions UserSubscription[]
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
@@ -369,7 +369,7 @@ model UserStripeCustomer {
model UserSubscription {
id Int @id @default(autoincrement()) @db.Integer
userId String @unique @map("user_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
plan String @db.VarChar(20)
// yearly/monthly
recurring String @db.VarChar(20)
@@ -395,6 +395,7 @@ model UserSubscription {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, plan])
@@map("user_subscriptions")
}

View File

@@ -1,7 +1,10 @@
import { execSync } from 'node:child_process';
import { generateKeyPairSync } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'dotenv';
const SELF_HOST_CONFIG_DIR = '/root/.affine/config';
/**
* @type {Array<{ from: string; to?: string, modifier?: (content: string): string }>}
@@ -36,6 +39,26 @@ function prepare() {
});
}
}
// make the default .env
if (to === '.env') {
const dotenvFile = fs.readFileSync(targetFilePath, 'utf-8');
const envs = parse(dotenvFile);
// generate a new private key
if (!envs.AFFINE_PRIVATE_KEY) {
const privateKey = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
}).privateKey.export({
type: 'sec1',
format: 'pem',
});
fs.writeFileSync(
targetFilePath,
`AFFINE_PRIVATE_KEY=${privateKey}\n` + dotenvFile
);
}
}
}
}

View File

@@ -18,11 +18,8 @@ import { UserModule } from './core/user';
import { WorkspaceModule } from './core/workspaces';
import { getOptionalModuleMetadata } from './fundamentals';
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
import {
type AvailablePlugins,
Config,
ConfigModule,
} from './fundamentals/config';
import type { AvailablePlugins } from './fundamentals/config';
import { Config, ConfigModule } from './fundamentals/config';
import { EventModule } from './fundamentals/event';
import { GqlModule } from './fundamentals/graphql';
import { HelpersModule } from './fundamentals/helpers';

View File

@@ -43,5 +43,12 @@ export async function createApp() {
app.useWebSocketAdapter(adapter);
}
if (AFFiNE.isSelfhosted && AFFiNE.telemetry.enabled) {
const mixpanel = await import('mixpanel');
mixpanel.init(AFFiNE.telemetry.token).track('selfhost-server-started', {
version: AFFiNE.version,
});
}
return app;
}

View File

@@ -39,7 +39,15 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
}
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment');
AFFiNE.plugins.use('payment', {
stripe: {
keys: {
// fake the key to ensure the server generate full GraphQL Schema even env vars are not set
APIKey: '1',
webhookKey: '1',
},
},
});
AFFiNE.plugins.use('oauth');
if (AFFiNE.deploy) {

View File

@@ -52,6 +52,18 @@ AFFiNE.port = 3010;
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
// AFFiNE.metrics.enabled = true;
//
// /* Authentication Settings */
// /* User Signup password limitation */
// AFFiNE.auth.password = {
// minLength: 8,
// maxLength: 32,
// };
//
// /* How long the login session would last by default */
// AFFiNE.auth.session = {
// ttl: 15 * 24 * 60 * 60, // 15 days
// };
//
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
// /* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server */
// AFFiNE.graphql = {
@@ -84,15 +96,15 @@ AFFiNE.port = 3010;
// /* Redis Plugin */
// /* Provide caching and session storing backed by Redis. */
// /* Useful when you deploy AFFiNE server in a cluster. */
AFFiNE.plugins.use('redis', {
/* override options */
});
// AFFiNE.plugins.use('redis', {
// /* override options */
// });
//
//
// /* Payment Plugin */
AFFiNE.plugins.use('payment', {
stripe: { keys: {}, apiVersion: '2023-10-16' },
});
// AFFiNE.plugins.use('payment', {
// stripe: { keys: {}, apiVersion: '2023-10-16' },
// });
//
//
// /* Cloudflare R2 Plugin */

View File

@@ -6,6 +6,7 @@ import {
Controller,
Get,
Header,
HttpStatus,
Post,
Query,
Req,
@@ -13,11 +14,7 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
Config,
PaymentRequiredException,
URLHelper,
} from '../../fundamentals';
import { PaymentRequiredException, URLHelper } from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -33,7 +30,6 @@ class SignInCredential {
@Controller('/api/auth')
export class AuthController {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
@@ -58,14 +54,13 @@ export class AuthController {
}
if (credential.password) {
validators.assertValidPassword(credential.password);
const user = await this.auth.signIn(
credential.email,
credential.password
);
await this.auth.setCookie(req, res, user);
res.send(user);
res.status(HttpStatus.OK).send(user);
} else {
// send email magic link
const user = await this.user.findUserByEmail(credential.email);
@@ -78,7 +73,7 @@ export class AuthController {
throw new Error('Failed to send sign-in email.');
}
res.send({
res.status(HttpStatus.OK).send({
email: credential.email,
});
}
@@ -142,6 +137,7 @@ export class AuthController {
}
email = decodeURIComponent(email);
token = decodeURIComponent(token);
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
@@ -162,22 +158,6 @@ export class AuthController {
return this.url.safeRedirect(res, redirectUri);
}
@Get('/authorize')
async authorize(
@CurrentUser() user: CurrentUser,
@Query('redirect_uri') redirect_uri?: string
) {
const session = await this.auth.createUserSession(
user,
undefined,
this.config.auth.accessToken.ttl
);
this.url.link(redirect_uri ?? '/open-app/redirect', {
token: session.sessionId,
});
}
@Public()
@Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {

View File

@@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { Config, getRequestResponseFromContext } from '../../fundamentals';
import { getRequestResponseFromContext } from '../../fundamentals';
import { AuthService, parseAuthUserSeqNum } from './service';
function extractTokenFromHeader(authorization: string) {
@@ -27,7 +27,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
constructor(
private readonly config: Config,
private readonly ref: ModuleRef,
private readonly reflector: Reflector
) {}
@@ -43,17 +42,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
// backward compatibility for client older then 0.12
// TODO: remove
if (!sessionToken) {
sessionToken =
req.cookies[
this.config.https
? '__Secure-next-auth.session-token'
: 'next-auth.session-token'
];
}
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}

View File

@@ -5,7 +5,7 @@ import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService } from './token';
import { TokenService, TokenType } from './token';
@Module({
imports: [FeatureModule, UserModule],
@@ -17,5 +17,5 @@ export class AuthModule {}
export * from './guard';
export { ClientTokenType } from './resolver';
export { AuthService };
export { AuthService, TokenService, TokenType };
export * from './current-user';

View File

@@ -17,6 +17,7 @@ import {
import type { Request, Response } from 'express';
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -48,6 +49,7 @@ export class AuthResolver {
constructor(
private readonly config: Config,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
) {}
@@ -132,7 +134,7 @@ export class AuthResolver {
@Args('email') email: string,
@Args('password') password: string
) {
validators.assertValidCredential({ email, password });
validators.assertValidEmail(email);
const user = await this.auth.signIn(email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
@@ -165,7 +167,7 @@ export class AuthResolver {
throw new ForbiddenException('Invalid token');
}
await this.auth.changePassword(user.email, newPassword);
await this.auth.changePassword(user.id, newPassword);
return user;
}
@@ -319,7 +321,7 @@ export class AuthResolver {
throw new ForbiddenException('Invalid token');
}
const hasRegistered = await this.auth.getUserByEmail(email);
const hasRegistered = await this.user.findUserByEmail(email);
if (hasRegistered) {
if (hasRegistered.id !== user.id) {

View File

@@ -2,37 +2,39 @@ import {
BadRequestException,
Injectable,
NotAcceptableException,
NotFoundException,
OnApplicationBootstrap,
} from '@nestjs/common';
import { PrismaClient, type User } from '@prisma/client';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import {
Config,
CryptoHelper,
MailService,
SessionCache,
} from '../../fundamentals';
import { Config, CryptoHelper, MailService } from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
import { UserService } from '../user/service';
import type { CurrentUser } from './current-user';
export function parseAuthUserSeqNum(value: any) {
let seq: number = 0;
switch (typeof value) {
case 'number': {
return value;
seq = value;
break;
}
case 'string': {
value = Number.parseInt(value);
return Number.isNaN(value) ? 0 : value;
const result = value.match(/^([\d{0, 10}])$/);
if (result?.[1]) {
seq = Number(result[1]);
}
break;
}
default: {
return 0;
seq = 0;
}
}
return Math.max(0, seq);
}
export function sessionUser(
@@ -56,10 +58,9 @@ export class AuthService implements OnApplicationBootstrap {
sameSite: 'lax',
httpOnly: true,
path: '/',
domain: this.config.host,
secure: this.config.https,
};
static readonly sessionCookieName = 'sid';
static readonly sessionCookieName = 'affine_session';
static readonly authUserSeqHeaderName = 'x-auth-user';
constructor(
@@ -68,8 +69,7 @@ export class AuthService implements OnApplicationBootstrap {
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly user: UserService,
private readonly crypto: CryptoHelper,
private readonly cache: SessionCache
private readonly crypto: CryptoHelper
) {}
async onApplicationBootstrap() {
@@ -89,7 +89,7 @@ export class AuthService implements OnApplicationBootstrap {
email: string,
password: string
): Promise<CurrentUser> {
const user = await this.getUserByEmail(email);
const user = await this.user.findUserByEmail(email);
if (user) {
throw new BadRequestException('Email was taken');
@@ -110,12 +110,12 @@ export class AuthService implements OnApplicationBootstrap {
const user = await this.user.findUserWithHashedPasswordByEmail(email);
if (!user) {
throw new NotFoundException('User Not Found');
throw new NotAcceptableException('Invalid sign in credentials');
}
if (!user.password) {
throw new NotAcceptableException(
'User Password is not set. Should login throw email link.'
'User Password is not set. Should login through email link.'
);
}
@@ -125,28 +125,12 @@ export class AuthService implements OnApplicationBootstrap {
);
if (!passwordMatches) {
throw new NotAcceptableException('Incorrect Password');
throw new NotAcceptableException('Invalid sign in credentials');
}
return sessionUser(user);
}
async getUserWithCache(token: string, seq = 0) {
const cacheKey = `session:${token}:${seq}`;
let user = await this.cache.get<CurrentUser | null>(cacheKey);
if (user) {
return user;
}
user = await this.getUser(token, seq);
if (user) {
await this.cache.set(cacheKey, user);
}
return user;
}
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
const session = await this.getSession(token);
@@ -197,7 +181,16 @@ export class AuthService implements OnApplicationBootstrap {
// Session
// | { user: LimitedUser { email, avatarUrl }, expired: true }
// | { user: User, expired: false }
return users.map(sessionUser);
return session.userSessions
.map(userSession => {
// keep users in the same order as userSessions
const user = users.find(({ id }) => id === userSession.userId);
if (!user) {
return null;
}
return sessionUser(user);
})
.filter(Boolean) as CurrentUser[];
}
async signOut(token: string, seq = 0) {
@@ -306,10 +299,11 @@ export class AuthService implements OnApplicationBootstrap {
}
}
async setCookie(req: Request, res: Response, user: { id: string }) {
async setCookie(_req: Request, res: Response, user: { id: string }) {
const session = await this.createUserSession(
user,
req.cookies[AuthService.sessionCookieName]
user
// TODO(@forehalo): enable multi user session
// req.cookies[AuthService.sessionCookieName]
);
res.cookie(AuthService.sessionCookieName, session.sessionId, {
@@ -318,12 +312,8 @@ export class AuthService implements OnApplicationBootstrap {
});
}
async getUserByEmail(email: string) {
return this.user.findUserByEmail(email);
}
async changePassword(email: string, newPassword: string): Promise<User> {
const user = await this.getUserByEmail(email);
async changePassword(id: string, newPassword: string): Promise<User> {
const user = await this.user.findUserById(id);
if (!user) {
throw new BadRequestException('Invalid email');
@@ -342,11 +332,7 @@ export class AuthService implements OnApplicationBootstrap {
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.db.user.findUnique({
where: {
id,
},
});
const user = await this.user.findUserById(id);
if (!user) {
throw new BadRequestException('Invalid email');

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import { CryptoHelper } from '../../fundamentals/helpers';
@@ -81,4 +82,15 @@ export class TokenService {
return valid ? record : null;
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
cleanExpiredTokens() {
return this.db.verificationToken.deleteMany({
where: {
expiresAt: {
lte: new Date(),
},
},
});
}
}

View File

@@ -22,6 +22,20 @@ export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
@ObjectType()
export class PasswordLimitsType {
@Field()
minLength!: number;
@Field()
maxLength!: number;
}
@ObjectType()
export class CredentialsRequirementType {
@Field()
password!: PasswordLimitsType;
}
@ObjectType()
export class ServerConfigType {
@Field({
@@ -47,6 +61,11 @@ export class ServerConfigType {
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field(() => CredentialsRequirementType, {
description: 'credentials requirement',
})
credentialsRequirement!: CredentialsRequirementType;
}
export class ServerConfigResolver {
@@ -65,6 +84,9 @@ export class ServerConfigResolver {
// this field should be removed after frontend feature flags implemented
flavor: AFFiNE.type,
features: Array.from(ENABLED_FEATURES),
credentialsRequirement: {
password: AFFiNE.auth.password,
},
};
}
}

View File

@@ -4,12 +4,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import {
Config,
type EventPayload,
metrics,
OnEvent,
} from '../../fundamentals';
import type { EventPayload } from '../../fundamentals';
import { Config, metrics, OnEvent } from '../../fundamentals';
import { QuotaService } from '../quota';
import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager';

View File

@@ -16,12 +16,12 @@ import {
transact,
} from 'yjs';
import type { EventPayload } from '../../fundamentals';
import {
Cache,
CallTimer,
Config,
EventEmitter,
type EventPayload,
mergeUpdatesInApplyWay as jwstMergeUpdates,
metrics,
OnEvent,
@@ -55,6 +55,16 @@ export function isEmptyBuffer(buf: Buffer): boolean {
const MAX_SEQ_NUM = 0x3fffffff; // u31
const UPDATES_QUEUE_CACHE_KEY = 'doc:manager:updates';
interface DocResponse {
doc: Doc;
timestamp: number;
}
interface BinaryResponse {
binary: Buffer;
timestamp: number;
}
/**
* Since we can't directly save all client updates into database, in which way the database will overload,
* we need to buffer the updates and merge them to reduce db write.
@@ -272,11 +282,15 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
updates: Buffer[],
retryTimes = 10
) {
const lastSeq = await this.getUpdateSeq(workspaceId, guid, updates.length);
const now = Date.now();
let timestamp = now;
await new Promise<void>((resolve, reject) => {
const timestamp = await new Promise<number>((resolve, reject) => {
defer(async () => {
const lastSeq = await this.getUpdateSeq(
workspaceId,
guid,
updates.length
);
const now = Date.now();
let timestamp = now;
let turn = 0;
const batchCount = 10;
for (const batch of chunk(updates, batchCount)) {
@@ -303,14 +317,16 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
});
turn++;
}
return timestamp;
})
.pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({
next: () => {
next: timestamp => {
this.logger.debug(
`pushed ${updates.length} updates for ${guid} in workspace ${workspaceId}`
);
resolve();
resolve(timestamp);
},
error: e => {
this.logger.error('Failed to push updates', e);
@@ -326,8 +342,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* Get latest timestamp of all docs in the workspace.
*/
@CallTimer('doc', 'get_stats')
async getStats(workspaceId: string, after: number | undefined = 0) {
@CallTimer('doc', 'get_doc_timestamps')
async getDocTimestamps(workspaceId: string, after: number | undefined = 0) {
const snapshots = await this.db.snapshot.findMany({
where: {
workspaceId,
@@ -372,13 +388,18 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* get the latest doc with all update applied.
*/
async get(workspaceId: string, guid: string): Promise<Doc | null> {
async get(workspaceId: string, guid: string): Promise<DocResponse | null> {
const result = await this._get(workspaceId, guid);
if (result) {
if ('doc' in result) {
return result.doc;
} else if ('snapshot' in result) {
return this.recoverDoc(result.snapshot);
return result;
} else {
const doc = await this.recoverDoc(result.binary);
return {
doc,
timestamp: result.timestamp,
};
}
}
@@ -388,13 +409,19 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* get the latest doc binary with all update applied.
*/
async getBinary(workspaceId: string, guid: string): Promise<Buffer | null> {
async getBinary(
workspaceId: string,
guid: string
): Promise<BinaryResponse | null> {
const result = await this._get(workspaceId, guid);
if (result) {
if ('doc' in result) {
return Buffer.from(encodeStateAsUpdate(result.doc));
} else if ('snapshot' in result) {
return result.snapshot;
return {
binary: Buffer.from(encodeStateAsUpdate(result.doc)),
timestamp: result.timestamp,
};
} else {
return result;
}
}
@@ -404,16 +431,27 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* get the latest doc state vector with all update applied.
*/
async getState(workspaceId: string, guid: string): Promise<Buffer | null> {
async getDocState(
workspaceId: string,
guid: string
): Promise<BinaryResponse | null> {
const snapshot = await this.getSnapshot(workspaceId, guid);
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
const doc = await this.squash(snapshot, updates);
return Buffer.from(encodeStateVector(doc));
const { doc, timestamp } = await this.squash(snapshot, updates);
return {
binary: Buffer.from(encodeStateVector(doc)),
timestamp,
};
}
return snapshot ? snapshot.state : null;
return snapshot?.state
? {
binary: snapshot.state,
timestamp: snapshot.updatedAt.getTime(),
}
: null;
}
/**
@@ -581,17 +619,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
private async _get(
workspaceId: string,
guid: string
): Promise<{ doc: Doc } | { snapshot: Buffer } | null> {
): Promise<DocResponse | BinaryResponse | null> {
const snapshot = await this.getSnapshot(workspaceId, guid);
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
return {
doc: await this.squash(snapshot, updates),
};
return this.squash(snapshot, updates);
}
return snapshot ? { snapshot: snapshot.blob } : null;
return snapshot
? { binary: snapshot.blob, timestamp: snapshot.updatedAt.getTime() }
: null;
}
/**
@@ -599,7 +637,10 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* and delete the updates records at the same time.
*/
@CallTimer('doc', 'squash')
private async squash(snapshot: Snapshot | null, updates: Update[]) {
private async squash(
snapshot: Snapshot | null,
updates: Update[]
): Promise<DocResponse> {
if (!updates.length) {
throw new Error('No updates to squash');
}
@@ -658,7 +699,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
await this.updateCachedUpdatesCount(workspaceId, id, -count);
}
return doc;
return { doc, timestamp: last.createdAt.getTime() };
}
private async getUpdateSeq(workspaceId: string, guid: string, batch = 1) {

View File

@@ -1,11 +1,8 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import {
type EventPayload,
OnEvent,
PrismaTransaction,
} from '../../fundamentals';
import type { EventPayload } from '../../fundamentals';
import { OnEvent, PrismaTransaction } from '../../fundamentals';
import { FeatureKind } from '../features';
import { QuotaConfig } from './quota';
import { QuotaType } from './types';

View File

@@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common';
import type {
BlobInputType,
EventPayload,
StorageProvider,
} from '../../../fundamentals';
import {
type BlobInputType,
Cache,
EventEmitter,
type EventPayload,
type ListObjectsMetadata,
OnEvent,
type StorageProvider,
StorageProviderFactory,
} from '../../../fundamentals';
@@ -17,13 +17,15 @@ export class WorkspaceBlobStorage {
constructor(
private readonly event: EventEmitter,
private readonly storageFactory: StorageProviderFactory
private readonly storageFactory: StorageProviderFactory,
private readonly cache: Cache
) {
this.provider = this.storageFactory.create('blob');
}
async put(workspaceId: string, key: string, blob: BlobInputType) {
await this.provider.put(`${workspaceId}/${key}`, blob);
await this.cache.delete(`blob-list:${workspaceId}`);
}
async get(workspaceId: string, key: string) {
@@ -31,6 +33,16 @@ export class WorkspaceBlobStorage {
}
async list(workspaceId: string) {
const cachedList = await this.cache.list<ListObjectsMetadata>(
`blob-list:${workspaceId}`,
0,
-1
);
if (cachedList.length > 0) {
return cachedList;
}
const blobs = await this.provider.list(workspaceId + '/');
blobs.forEach(item => {
@@ -38,6 +50,8 @@ export class WorkspaceBlobStorage {
item.key = item.key.slice(workspaceId.length + 1);
});
await this.cache.pushBack(`blob-list:${workspaceId}`, ...blobs);
return blobs;
}

View File

@@ -246,7 +246,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
): Promise<EventResponse<Record<string, number>>> {
this.assertInWorkspace(client, Sync(workspaceId));
const stats = await this.docManager.getStats(workspaceId, timestamp);
const stats = await this.docManager.getDocTimestamps(
workspaceId,
timestamp
);
return {
data: stats,
@@ -302,13 +305,15 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
guid: string;
stateVector?: string;
}
): Promise<EventResponse<{ missing: string; state?: string }>> {
): Promise<
EventResponse<{ missing: string; state?: string; timestamp: number }>
> {
this.assertInWorkspace(client, Sync(workspaceId));
const docId = new DocID(guid, workspaceId);
const doc = await this.docManager.get(docId.workspace, docId.guid);
const res = await this.docManager.get(docId.workspace, docId.guid);
if (!doc) {
if (!res) {
return {
error: new DocNotFoundError(workspaceId, docId.guid),
};
@@ -316,16 +321,17 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
const missing = Buffer.from(
encodeStateAsUpdate(
doc,
res.doc,
stateVector ? Buffer.from(stateVector, 'base64') : undefined
)
).toString('base64');
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
const state = Buffer.from(encodeStateVector(res.doc)).toString('base64');
return {
data: {
missing,
state,
timestamp: res.timestamp,
},
};
}

View File

@@ -7,14 +7,15 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PrismaClient, type User } from '@prisma/client';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { isNil, omitBy } from 'lodash-es';
import type { FileUpload } from '../../fundamentals';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
PaymentRequiredException,
Throttle,
} from '../../fundamentals';

View File

@@ -5,12 +5,13 @@ function getAuthCredentialValidator() {
const email = z.string().email({ message: 'Invalid email address' });
let password = z.string();
const minPasswordLength = AFFiNE.node.prod ? 8 : 1;
password = password
.min(minPasswordLength, {
message: `Password must be ${minPasswordLength} or more charactors long`,
.min(AFFiNE.auth.password.minLength, {
message: `Password must be ${AFFiNE.auth.password.minLength} or more charactors long`,
})
.max(20, { message: 'Password must be 20 or fewer charactors long' });
.max(AFFiNE.auth.password.maxLength, {
message: `Password must be ${AFFiNE.auth.password.maxLength} or fewer charactors long`,
});
return z
.object({

View File

@@ -51,7 +51,7 @@ export class WorkspacesController {
// metadata should always exists if body is not null
if (metadata) {
res.setHeader('content-type', metadata.contentType);
res.setHeader('last-modified', metadata.lastModified.toISOString());
res.setHeader('last-modified', metadata.lastModified.toUTCString());
res.setHeader('content-length', metadata.contentLength);
} else {
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
@@ -83,9 +83,12 @@ export class WorkspacesController {
throw new ForbiddenException('Permission denied');
}
const update = await this.docManager.getBinary(docId.workspace, docId.guid);
const binResponse = await this.docManager.getBinary(
docId.workspace,
docId.guid
);
if (!update) {
if (!binResponse) {
throw new NotFoundException('Doc not found');
}
@@ -106,8 +109,12 @@ export class WorkspacesController {
}
res.setHeader('content-type', 'application/octet-stream');
res.setHeader('cache-control', 'no-cache');
res.send(update);
res.setHeader(
'last-modified',
new Date(binResponse.timestamp).toUTCString()
);
res.setHeader('cache-control', 'private, max-age=2592000');
res.send(binResponse.binary);
}
@Get('/:id/docs/:guid/histories/:timestamp')
@@ -142,7 +149,7 @@ export class WorkspacesController {
if (history) {
res.setHeader('content-type', 'application/octet-stream');
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
res.send(history.blob);
} else {
throw new NotFoundException('Doc history not found');

View File

@@ -1,5 +1,6 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { type Prisma, PrismaClient } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { Permission } from './types';

View File

@@ -16,9 +16,9 @@ import {
import { SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../fundamentals';
import {
CloudThrottlerGuard,
type FileUpload,
MakeCache,
PreventCache,
} from '../../../fundamentals';

View File

@@ -9,10 +9,8 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
PrismaClient,
type WorkspacePage as PrismaWorkspacePage,
} from '@prisma/client';
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { CurrentUser } from '../../auth';

View File

@@ -20,10 +20,10 @@ import { getStreamAsBuffer } from 'get-stream';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import type { FileUpload } from '../../../fundamentals';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
MailService,
MutexService,
Throttle,

View File

@@ -3,9 +3,8 @@ import '../prelude';
import { Logger } from '@nestjs/common';
import { CommandFactory } from 'nest-commander';
import { CliAppModule } from './app';
async function bootstrap() {
const { CliAppModule } = await import('./app');
await CommandFactory.run(CliAppModule, new Logger()).catch(e => {
console.error(e);
process.exit(1);

View File

@@ -1,4 +1,5 @@
import { PrismaClient, type User } from '@prisma/client';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
export class UnamedAccount1703756315970 {
// do the migration

View File

@@ -13,12 +13,6 @@ declare global {
}
}
export enum ExternalAccount {
github = 'github',
google = 'google',
firebase = 'firebase',
}
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
export type NODE_ENV = 'development' | 'test' | 'production';
@@ -220,6 +214,25 @@ export interface AFFiNEConfig {
* authentication config
*/
auth: {
/**
* The minimum and maximum length of the password when registering new users
*
* @default [8,32]
*/
password: {
/**
* The minimum length of the password
*
* @default 8
*/
minLength: number;
/**
* The maximum length of the password
*
* @default 32
*/
maxLength: number;
};
session: {
/**
* Application auth expiration time in seconds
@@ -319,6 +332,11 @@ export interface AFFiNEConfig {
metrics: {
enabled: boolean;
};
telemetry: {
enabled: boolean;
token: string;
};
}
export * from './storage';

View File

@@ -5,13 +5,8 @@ import { createPrivateKey, createPublicKey } from 'node:crypto';
import { merge } from 'lodash-es';
import pkg from '../../../package.json' assert { type: 'json' };
import {
type AFFINE_ENV,
AFFiNEConfig,
DeploymentType,
type NODE_ENV,
type ServerFlavor,
} from './def';
import type { AFFINE_ENV, NODE_ENV, ServerFlavor } from './def';
import { AFFiNEConfig, DeploymentType } from './def';
import { readEnv } from './env';
import { getDefaultAFFiNEStorageConfig } from './storage';
@@ -25,9 +20,10 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
const ONE_DAY_IN_SEC = 60 * 60 * 24;
const keyPair = (function () {
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
const AFFINE_PRIVATE_KEY =
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AUTH_PRIVATE_KEY),
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'sec1',
})
@@ -37,7 +33,7 @@ const keyPair = (function () {
})
.toString('utf8');
const publicKey = createPublicKey({
key: Buffer.from(AUTH_PRIVATE_KEY),
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'spki',
})
@@ -77,7 +73,16 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
Object.values(DeploymentType)
);
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
const affine = {
canary: AFFINE_ENV === 'dev',
beta: AFFINE_ENV === 'beta',
stable: AFFINE_ENV === 'production',
};
const node = {
prod: NODE_ENV === 'production',
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
const defaultConfig = {
serverId: 'affine-nestjs-server',
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
@@ -98,19 +103,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
ENV_MAP: {},
AFFINE_ENV,
get affine() {
return {
canary: AFFINE_ENV === 'dev',
beta: AFFINE_ENV === 'beta',
stable: AFFINE_ENV === 'production',
};
return affine;
},
NODE_ENV,
get node() {
return {
prod: NODE_ENV === 'production',
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
return node;
},
get deploy() {
return !this.node.dev && !this.node.test;
@@ -150,6 +147,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
playground: true,
},
auth: {
password: {
minLength: node.prod ? 8 : 1,
maxLength: 32,
},
session: {
ttl: 15 * ONE_DAY_IN_SEC,
},
@@ -186,6 +187,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
metrics: {
enabled: false,
},
telemetry: {
enabled: isSelfhosted && !process.env.DISABLE_SERVER_TELEMETRY,
token: '389c0615a69b57cca7d3fa0a4824c930',
},
plugins: {
enabled: new Set(),
use(plugin, config) {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type Response } from 'express';
import type { Response } from 'express';
import { Config } from '../config';

View File

@@ -18,13 +18,7 @@ export type { GraphqlContext } from './graphql';
export { CryptoHelper, URLHelper } from './helpers';
export { MailService } from './mailer';
export { CallCounter, CallTimer, metrics } from './metrics';
export {
BucketService,
LockGuard,
MUTEX_RETRY,
MUTEX_WAIT,
MutexService,
} from './mutex';
export { type ILocker, Lock, Locker, MutexService } from './mutex';
export {
getOptionalModuleMetadata,
GlobalExceptionFilter,

View File

@@ -2,7 +2,8 @@ import { Inject, Injectable, Optional } from '@nestjs/common';
import { Config } from '../config';
import { URLHelper } from '../helpers';
import { MAILER_SERVICE, type MailerService, type Options } from './mailer';
import type { MailerService, Options } from './mailer';
import { MAILER_SERVICE } from './mailer';
import { emailTemplate } from './template';
@Injectable()
export class MailService {

View File

@@ -43,9 +43,9 @@ const metricCreators: MetricCreators = {
gauge(meter: Meter, name: string, opts?: MetricOptions) {
let value: any;
let attrs: Attributes | undefined;
const ob = meter.createObservableGauge(name, opts);
const ob$ = meter.createObservableGauge(name, opts);
ob.addCallback(result => {
ob$.addCallback(result => {
result.observe(value, attrs);
});

View File

@@ -15,11 +15,8 @@ import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
import { Resource } from '@opentelemetry/resources';
import {
type MeterProvider,
MetricProducer,
MetricReader,
} from '@opentelemetry/sdk-metrics';
import type { MeterProvider } from '@opentelemetry/sdk-metrics';
import { MetricProducer, MetricReader } from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import {
BatchSpanProcessor,

View File

@@ -1,15 +0,0 @@
export class BucketService {
private readonly bucket = new Map<string, string>();
get(key: string) {
return this.bucket.get(key);
}
set(key: string, value: string) {
this.bucket.set(key, value);
}
delete(key: string) {
this.bucket.delete(key);
}
}

View File

@@ -1,14 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { BucketService } from './bucket';
import { Locker } from './local-lock';
import { MutexService } from './mutex';
@Global()
@Module({
providers: [BucketService, MutexService],
exports: [BucketService, MutexService],
providers: [MutexService, Locker],
exports: [MutexService],
})
export class MutexModule {}
export { BucketService, MutexService };
export { LockGuard, MUTEX_RETRY, MUTEX_WAIT } from './mutex';
export { Locker, MutexService };
export { type Locker as ILocker, Lock } from './lock';

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Cache } from '../cache';
import { Lock, Locker as ILocker } from './lock';
@Injectable()
export class Locker implements ILocker {
constructor(private readonly cache: Cache) {}
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
const prevOwner = await this.cache.get<string>(lockKey);
if (prevOwner && prevOwner !== owner) {
throw new Error(`Lock for resource [${key}] has been holder by others`);
}
const acquired = await this.cache.set(lockKey, owner);
if (acquired) {
return new Lock(async () => {
await this.cache.delete(lockKey);
});
}
throw new Error(`Failed to acquire lock for resource [${key}]`);
}
}

View File

@@ -0,0 +1,23 @@
import { Logger } from '@nestjs/common';
import { retryable } from '../utils/promise';
export class Lock implements AsyncDisposable {
private readonly logger = new Logger(Lock.name);
constructor(private readonly dispose: () => Promise<void>) {}
async release() {
await retryable(() => this.dispose()).catch(e => {
this.logger.error('Failed to release lock', e);
});
}
async [Symbol.asyncDispose]() {
await this.release();
}
}
export interface Locker {
lock(owner: string, key: string): Promise<Lock>;
}

View File

@@ -1,24 +1,12 @@
import { randomUUID } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable, Logger, Scope } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { CONTEXT } from '@nestjs/graphql';
import type { GraphqlContext } from '../graphql';
import { BucketService } from './bucket';
export class LockGuard<M extends MutexService = MutexService>
implements AsyncDisposable
{
constructor(
private readonly mutex: M,
private readonly key: string
) {}
async [Symbol.asyncDispose]() {
return this.mutex.unlock(this.key);
}
}
import { retryable } from '../utils/promise';
import { Locker } from './local-lock';
export const MUTEX_RETRY = 5;
export const MUTEX_WAIT = 100;
@@ -26,11 +14,21 @@ export const MUTEX_WAIT = 100;
@Injectable({ scope: Scope.REQUEST })
export class MutexService {
protected logger = new Logger(MutexService.name);
private readonly locker: Locker;
constructor(
@Inject(CONTEXT) private readonly context: GraphqlContext,
private readonly bucket: BucketService
) {}
private readonly ref: ModuleRef
) {
// nestjs will always find and injecting the locker from local module
// so the RedisLocker implemented by the plugin mechanism will not be able to overwrite the internal locker
// we need to use find and get the locker from the `ModuleRef` manually
//
// NOTE: when a `constructor` execute in normal service, the Locker module we expect may not have been initialized
// but in the Service with `Scope.REQUEST`, we will create a separate Service instance for each request
// at this time, all modules have been initialized, so we able to get the correct Locker instance in `constructor`
this.locker = this.ref.get(Locker, { strict: false });
}
protected getId() {
let id = this.context.req.headers['x-transaction-id'] as string;
@@ -64,33 +62,19 @@ export class MutexService {
* @param key resource key
* @returns LockGuard
*/
async lock(key: string): Promise<LockGuard | undefined> {
const id = this.getId();
const fetchLock = async (retry: number): Promise<LockGuard | undefined> => {
if (retry === 0) {
this.logger.error(
`Failed to fetch lock ${key} after ${MUTEX_RETRY} retry`
);
return undefined;
}
const current = this.bucket.get(key);
if (current && current !== id) {
this.logger.warn(
`Failed to fetch lock ${key}, retrying in ${MUTEX_WAIT} ms`
);
await setTimeout(MUTEX_WAIT * (MUTEX_RETRY - retry + 1));
return fetchLock(retry - 1);
}
this.bucket.set(key, id);
return new LockGuard(this, key);
};
return fetchLock(MUTEX_RETRY);
}
async unlock(key: string): Promise<void> {
if (this.bucket.get(key) === this.getId()) {
this.bucket.delete(key);
async lock(key: string) {
try {
return await retryable(
() => this.locker.lock(this.getId(), key),
MUTEX_RETRY,
MUTEX_WAIT
);
} catch (e) {
this.logger.error(
`Failed to lock resource [${key}] after retry ${MUTEX_RETRY} times`,
e
);
return undefined;
}
}
}

View File

@@ -6,7 +6,13 @@ import { PrismaService } from './service';
// only `PrismaClient` can be injected
const clientProvider: Provider = {
provide: PrismaClient,
useClass: PrismaService,
useFactory: () => {
if (PrismaService.INSTANCE) {
return PrismaService.INSTANCE;
}
return new PrismaService();
},
};
@Global()

View File

@@ -19,6 +19,9 @@ export class PrismaService
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
if (!AFFiNE.node.test) {
await this.$disconnect();
PrismaService.INSTANCE = null;
}
}
}

View File

@@ -1,5 +1,4 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
import {
Throttle,
ThrottlerGuard,

View File

@@ -0,0 +1,44 @@
import { defer, retry } from 'rxjs';
export class RetryablePromise<T> extends Promise<T> {
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void
) => void,
retryTimes: number = 3,
retryIntervalInMs: number = 300
) {
super((resolve, reject) => {
defer(() => new Promise<T>(executor))
.pipe(
retry({
count: retryTimes,
delay: retryIntervalInMs,
})
)
.subscribe({
next: v => {
resolve(v);
},
error: e => {
reject(e);
},
});
});
}
}
export function retryable<Ret = unknown>(
asyncFn: () => Promise<Ret>,
retryTimes = 3,
retryIntervalInMs = 300
): Promise<Ret> {
return new RetryablePromise<Ret>(
(resolve, reject) => {
asyncFn().then(resolve).catch(reject);
},
retryTimes,
retryIntervalInMs
);
}

View File

@@ -40,7 +40,7 @@ export class OAuthController {
const provider = this.providerFactory.get(providerName);
if (!provider) {
throw new BadRequestException('Invalid provider');
throw new BadRequestException('Invalid OAuth provider');
}
const state = await this.oauth.saveOAuthState({

View File

@@ -36,7 +36,7 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
redirect_uri: this.url.link('/oauth/callback'),
response_type: 'code',
scope: 'openid email profile',
promot: 'select_account',
prompt: 'select_account',
access_type: 'offline',
...this.config.args,
state,

View File

@@ -16,7 +16,7 @@ export function registerOAuthProvider(
@Injectable()
export class OAuthProviderFactory {
get providers() {
return PROVIDERS.keys();
return Array.from(PROVIDERS.keys());
}
get(name: OAuthProviderName): OAuthProvider | undefined {

View File

@@ -1,8 +1,4 @@
import {
BadGatewayException,
ForbiddenException,
InternalServerErrorException,
} from '@nestjs/common';
import { BadGatewayException, ForbiddenException } from '@nestjs/common';
import {
Args,
Context,
@@ -48,11 +44,11 @@ class SubscriptionPrice {
@Field()
currency!: string;
@Field()
amount!: number;
@Field(() => Int, { nullable: true })
amount?: number | null;
@Field()
yearlyAmount!: number;
@Field(() => Int, { nullable: true })
yearlyAmount?: number | null;
}
@ObjectType('UserSubscription')
@@ -176,64 +172,39 @@ export class SubscriptionResolver {
}
);
return Object.entries(group).map(([plan, prices]) => {
const yearly = prices.find(
price =>
decodeLookupKey(
// @ts-expect-error empty lookup key is filtered out
price.lookup_key
)[1] === SubscriptionRecurring.Yearly
);
const monthly = prices.find(
price =>
decodeLookupKey(
// @ts-expect-error empty lookup key is filtered out
price.lookup_key
)[1] === SubscriptionRecurring.Monthly
);
function findPrice(plan: SubscriptionPlan) {
const prices = group[plan];
if (!yearly || !monthly) {
throw new InternalServerErrorException(
'The prices are not configured correctly.'
);
if (!prices) {
return null;
}
const monthlyPrice = prices.find(p => p.recurring?.interval === 'month');
const yearlyPrice = prices.find(p => p.recurring?.interval === 'year');
const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
return {
type: 'fixed',
plan: plan as SubscriptionPlan,
currency: monthly.currency,
amount: monthly.unit_amount ?? 0,
yearlyAmount: yearly.unit_amount ?? 0,
currency,
amount: monthlyPrice?.unit_amount,
yearlyAmount: yearlyPrice?.unit_amount,
};
});
}
/**
* @deprecated
*/
@Mutation(() => String, {
deprecationReason: 'use `createCheckoutSession` instead',
description: 'Create a subscription checkout link of stripe',
})
async checkout(
@CurrentUser() user: CurrentUser,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring,
@Args('idempotencyKey') idempotencyKey: string
) {
const session = await this.service.createCheckoutSession({
user,
plan: SubscriptionPlan.Pro,
recurring,
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
idempotencyKey,
});
if (!session.url) {
throw new BadGatewayException('Failed to create checkout session.');
}
return session.url;
// extend it when new plans are added
const fixedPlans = [SubscriptionPlan.Pro, SubscriptionPlan.AI];
return fixedPlans.reduce((prices, plan) => {
const price = findPrice(plan);
if (price && (price.amount || price.yearlyAmount)) {
prices.push({
type: 'fixed',
plan,
...price,
});
}
return prices;
}, [] as SubscriptionPrice[]);
}
@Mutation(() => String, {
@@ -271,17 +242,35 @@ export class SubscriptionResolver {
@Mutation(() => UserSubscriptionType)
async cancelSubscription(
@CurrentUser() user: CurrentUser,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.cancelSubscription(idempotencyKey, user.id);
return this.service.cancelSubscription(idempotencyKey, user.id, plan);
}
@Mutation(() => UserSubscriptionType)
async resumeSubscription(
@CurrentUser() user: CurrentUser,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
return this.service.resumeCanceledSubscription(
idempotencyKey,
user.id,
plan
);
}
@Mutation(() => UserSubscriptionType)
@@ -289,11 +278,19 @@ export class SubscriptionResolver {
@CurrentUser() user: CurrentUser,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.updateSubscriptionRecurring(
idempotencyKey,
user.id,
plan,
recurring
);
}
@@ -306,11 +303,21 @@ export class UserSubscriptionResolver {
private readonly db: PrismaClient
) {}
@ResolveField(() => UserSubscriptionType, { nullable: true })
@ResolveField(() => UserSubscriptionType, {
nullable: true,
deprecationReason: 'use `UserType.subscriptions`',
})
async subscription(
@Context() ctx: { isAdminQuery: boolean },
@CurrentUser() me: User,
@Parent() user: User
@Parent() user: User,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan
) {
// allow admin to query other user's subscription
if (!ctx.isAdminQuery && me.id !== user.id) {
@@ -340,12 +347,33 @@ export class UserSubscriptionResolver {
return this.db.userSubscription.findUnique({
where: {
userId: user.id,
userId_plan: {
userId: user.id,
plan,
},
status: SubscriptionStatus.Active,
},
});
}
@ResolveField(() => [UserSubscriptionType])
async subscriptions(
@CurrentUser() me: User,
@Parent() user: User
): Promise<UserSubscription[]> {
if (me.id !== user.id) {
throw new ForbiddenException(
'You are not allowed to access this subscription.'
);
}
return this.db.userSubscription.findMany({
where: {
userId: user.id,
},
});
}
@ResolveField(() => [UserInvoiceType])
async invoices(
@CurrentUser() me: User,

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
import type {
Prisma,
@@ -65,7 +65,9 @@ export class SubscriptionService {
) {}
async listPrices() {
return this.stripe.prices.list();
return this.stripe.prices.list({
active: true,
});
}
async createCheckoutSession({
@@ -86,12 +88,15 @@ export class SubscriptionService {
const currentSubscription = await this.db.userSubscription.findFirst({
where: {
userId: user.id,
plan,
status: SubscriptionStatus.Active,
},
});
if (currentSubscription) {
throw new Error('You already have a subscription');
throw new BadRequestException(
`You've already subscripted to the ${plan} plan`
);
}
const price = await this.getPrice(plan, recurring);
@@ -152,35 +157,47 @@ export class SubscriptionService {
async cancelSubscription(
idempotencyKey: string,
userId: string
userId: string,
plan: SubscriptionPlan
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
where: {
id: userId,
},
include: {
subscription: true,
subscriptions: {
where: {
plan,
},
},
},
});
if (!user?.subscription) {
throw new Error('You do not have any subscription');
if (!user) {
throw new BadRequestException('Unknown user');
}
if (user.subscription.canceledAt) {
throw new Error('Your subscription has already been canceled');
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`);
}
if (subscriptionInDB.canceledAt) {
throw new BadRequestException(
'Your subscription has already been canceled'
);
}
// should release the schedule first
if (user.subscription.stripeScheduleId) {
if (subscriptionInDB.stripeScheduleId) {
const manager = await this.scheduleManager.fromSchedule(
user.subscription.stripeScheduleId
subscriptionInDB.stripeScheduleId
);
await manager.cancel(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
subscriptionInDB.stripeSubscriptionId
),
false
);
@@ -188,7 +205,7 @@ export class SubscriptionService {
// let customer contact support if they want to cancel immediately
// see https://stripe.com/docs/billing/subscriptions/cancel
const subscription = await this.stripe.subscriptions.update(
user.subscription.stripeSubscriptionId,
subscriptionInDB.stripeSubscriptionId,
{ cancel_at_period_end: true },
{ idempotencyKey }
);
@@ -198,44 +215,52 @@ export class SubscriptionService {
async resumeCanceledSubscription(
idempotencyKey: string,
userId: string
userId: string,
plan: SubscriptionPlan
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
where: {
id: userId,
},
include: {
subscription: true,
subscriptions: true,
},
});
if (!user?.subscription) {
throw new Error('You do not have any subscription');
if (!user) {
throw new BadRequestException('Unknown user');
}
if (!user.subscription.canceledAt) {
throw new Error('Your subscription has not been canceled');
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`);
}
if (user.subscription.end < new Date()) {
throw new Error('Your subscription is expired, please checkout again.');
if (!subscriptionInDB.canceledAt) {
throw new BadRequestException('Your subscription has not been canceled');
}
if (user.subscription.stripeScheduleId) {
if (subscriptionInDB.end < new Date()) {
throw new BadRequestException(
'Your subscription is expired, please checkout again.'
);
}
if (subscriptionInDB.stripeScheduleId) {
const manager = await this.scheduleManager.fromSchedule(
user.subscription.stripeScheduleId
subscriptionInDB.stripeScheduleId
);
await manager.resume(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
subscriptionInDB.stripeSubscriptionId
),
false
);
} else {
const subscription = await this.stripe.subscriptions.update(
user.subscription.stripeSubscriptionId,
subscriptionInDB.stripeSubscriptionId,
{ cancel_at_period_end: false },
{ idempotencyKey }
);
@@ -247,6 +272,7 @@ export class SubscriptionService {
async updateSubscriptionRecurring(
idempotencyKey: string,
userId: string,
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
@@ -254,30 +280,38 @@ export class SubscriptionService {
id: userId,
},
include: {
subscription: true,
subscriptions: true,
},
});
if (!user?.subscription) {
throw new Error('You do not have any subscription');
if (!user) {
throw new BadRequestException('Unknown user');
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`);
}
if (user.subscription.canceledAt) {
throw new Error('Your subscription has already been canceled ');
if (subscriptionInDB.canceledAt) {
throw new BadRequestException(
'Your subscription has already been canceled '
);
}
if (user.subscription.recurring === recurring) {
throw new Error('You have already subscribed to this plan');
if (subscriptionInDB.recurring === recurring) {
throw new BadRequestException(
`You are already in ${recurring} recurring`
);
}
const price = await this.getPrice(
user.subscription.plan as SubscriptionPlan,
subscriptionInDB.plan as SubscriptionPlan,
recurring
);
const manager = await this.scheduleManager.fromSubscription(
`${idempotencyKey}-fromSubscription`,
user.subscription.stripeSubscriptionId
subscriptionInDB.stripeSubscriptionId
);
await manager.update(
@@ -293,7 +327,7 @@ export class SubscriptionService {
return await this.db.userSubscription.update({
where: {
id: user.subscription.id,
id: subscriptionInDB.id,
},
data: {
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
@@ -310,7 +344,7 @@ export class SubscriptionService {
});
if (!user) {
throw new Error('Unknown user');
throw new BadRequestException('Unknown user');
}
try {
@@ -321,7 +355,7 @@ export class SubscriptionService {
return portal.url;
} catch (e) {
this.logger.error('Failed to create customer portal.', e);
throw new Error('Failed to create customer portal');
throw new BadRequestException('Failed to create customer portal');
}
}
@@ -518,7 +552,10 @@ export class SubscriptionService {
const currentSubscription = await this.db.userSubscription.findUnique({
where: {
userId: user.id,
userId_plan: {
userId: user.id,
plan,
},
},
});
@@ -641,8 +678,8 @@ export class SubscriptionService {
});
if (!prices.data.length) {
throw new Error(
`Unknown subscription plan ${plan} with recurring ${recurring}`
throw new BadRequestException(
`Unknown subscription plan ${plan} with ${recurring} recurring`
);
}

View File

@@ -1,5 +1,5 @@
import { type User } from '@prisma/client';
import { type Stripe } from 'stripe';
import type { User } from '@prisma/client';
import type { Stripe } from 'stripe';
import type { Payload } from '../../fundamentals/event/def';
@@ -20,6 +20,7 @@ export enum SubscriptionRecurring {
export enum SubscriptionPlan {
Free = 'free',
Pro = 'pro',
AI = 'ai',
Team = 'team',
Enterprise = 'enterprise',
SelfHosted = 'selfhosted',

View File

@@ -1,27 +1,15 @@
import { Global, Provider, Type } from '@nestjs/common';
import { CONTEXT } from '@nestjs/graphql';
import { Redis, type RedisOptions } from 'ioredis';
import type { RedisOptions } from 'ioredis';
import { Redis } from 'ioredis';
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
import {
BucketService,
Cache,
type GraphqlContext,
MutexService,
SessionCache,
} from '../../fundamentals';
import { Cache, Locker, SessionCache } from '../../fundamentals';
import { ThrottlerStorage } from '../../fundamentals/throttler';
import { SocketIoAdapterImpl } from '../../fundamentals/websocket';
import { Plugin } from '../registry';
import { RedisCache } from './cache';
import {
CacheRedis,
MutexRedis,
SessionRedis,
SocketIoRedis,
ThrottlerRedis,
} from './instances';
import { MutexRedisService } from './mutex';
import { CacheRedis, SessionRedis, SocketIoRedis } from './instances';
import { RedisMutexLocker } from './mutex';
import { createSockerIoAdapterImpl } from './ws-adapter';
function makeProvider(token: Type, impl: Type<Redis>): Provider {
@@ -44,7 +32,7 @@ const throttlerStorageProvider: Provider = {
useFactory: (redis: Redis) => {
return new ThrottlerStorageRedisService(redis);
},
inject: [ThrottlerRedis],
inject: [SessionRedis],
};
// socket io
@@ -58,23 +46,14 @@ const socketIoRedisAdapterProvider: Provider = {
// mutex
const mutexRedisAdapterProvider: Provider = {
provide: MutexService,
useFactory: (redis: Redis, ctx: GraphqlContext, bucket: BucketService) => {
return new MutexRedisService(redis, ctx, bucket);
},
inject: [MutexRedis, CONTEXT, BucketService],
provide: Locker,
useClass: RedisMutexLocker,
};
@Global()
@Plugin({
name: 'redis',
providers: [
CacheRedis,
SessionRedis,
ThrottlerRedis,
SocketIoRedis,
MutexRedis,
],
providers: [CacheRedis, SessionRedis, SocketIoRedis],
overrides: [
cacheProvider,
sessionCacheProvider,

View File

@@ -34,13 +34,6 @@ export class CacheRedis extends Redis {
}
}
@Injectable()
export class ThrottlerRedis extends Redis {
constructor(config: Config) {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 1 });
}
}
@Injectable()
export class SessionRedis extends Redis {
constructor(config: Config) {
@@ -54,10 +47,3 @@ export class SocketIoRedis extends Redis {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 3 });
}
}
@Injectable()
export class MutexRedis extends Redis {
constructor(config: Config) {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 4 });
}
}

View File

@@ -1,96 +1,65 @@
import { setTimeout } from 'node:timers/promises';
import { Injectable, Logger } from '@nestjs/common';
import Redis, { Command } from 'ioredis';
import { Command } from 'ioredis';
import {
BucketService,
type GraphqlContext,
LockGuard,
MUTEX_RETRY,
MUTEX_WAIT,
MutexService,
} from '../../fundamentals';
import { ILocker, Lock } from '../../fundamentals';
import { SessionRedis } from './instances';
// === atomic mutex lock ===
// acquire lock
// return 1 if lock is acquired
// return 0 if lock is not acquired
const lockScript = `local key = KEYS[1]
local clientId = ARGV[1]
local releaseTime = ARGV[2]
local owner = ARGV[1]
if redis.call("get", key) == clientId or redis.call("set", key, clientId, "NX", "PX", releaseTime) then
-- if lock is not exists or lock is owned by the owner
-- then set lock to the owner and return 1, otherwise return 0
-- if the lock is not released correctly due to unexpected reasons
-- lock will be released after 60 seconds
if redis.call("get", key) == owner or redis.call("set", key, owner, "NX", "EX", 60) then
return 1
else
return 0
end`;
// release lock
// return 1 if lock is released or lock is not exists
// return 0 if lock is not owned by the owner
const unlockScript = `local key = KEYS[1]
local clientId = ARGV[1]
local owner = ARGV[1]
if redis.call("get", key) == clientId then
local value = redis.call("get", key)
if value == owner then
return redis.call("del", key)
elseif value == nil then
return 1
else
return 0
end`;
@Injectable()
export class MutexRedisService extends MutexService {
constructor(
private readonly redis: Redis,
context: GraphqlContext,
bucket: BucketService
) {
super(context, bucket);
this.logger = new Logger(MutexRedisService.name);
}
export class RedisMutexLocker implements ILocker {
private readonly logger = new Logger(RedisMutexLocker.name);
constructor(private readonly redis: SessionRedis) {}
override async lock(
key: string,
releaseTimeInMS: number = 200
): Promise<LockGuard | undefined> {
const clientId = this.getId();
this.logger.debug(`Client ${clientId} lock try to lock ${key}`);
const releaseTime = releaseTimeInMS.toString();
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
this.logger.debug(`Client ${owner} is trying to lock resource ${key}`);
const fetchLock = async (retry: number): Promise<LockGuard | undefined> => {
if (retry === 0) {
this.logger.error(
`Failed to fetch lock ${key} after ${MUTEX_RETRY} retry`
);
return undefined;
}
try {
const success = await this.redis.sendCommand(
new Command('EVAL', [lockScript, '1', key, clientId, releaseTime])
);
if (success === 1) {
return new LockGuard(this, key);
} else {
this.logger.warn(
`Failed to fetch lock ${key}, retrying in ${MUTEX_WAIT} ms`
);
await setTimeout(MUTEX_WAIT * (MUTEX_RETRY - retry + 1));
return fetchLock(retry - 1);
}
} catch (error: any) {
this.logger.error(
`Unexpected error when fetch lock ${key}: ${error.message}`
);
return undefined;
}
};
return fetchLock(MUTEX_RETRY);
}
override async unlock(key: string, ignoreUnlockFail = false): Promise<void> {
const clientId = this.getId();
const result = await this.redis.sendCommand(
new Command('EVAL', [unlockScript, '1', key, clientId])
const success = await this.redis.sendCommand(
new Command('EVAL', [lockScript, '1', lockKey, owner])
);
if (result === 0) {
if (!ignoreUnlockFail) {
throw new Error(`Failed to release lock ${key}`);
} else {
this.logger.warn(`Failed to release lock ${key}`);
}
if (success === 1) {
return new Lock(async () => {
const result = await this.redis.sendCommand(
new Command('EVAL', [unlockScript, '1', lockKey, owner])
);
if (result === 0) {
throw new Error(`Failed to release lock ${key}`);
}
});
}
throw new Error(`Failed to acquire lock for resource [${key}]`);
}
}

View File

@@ -10,6 +10,10 @@ input CreateCheckoutSessionInput {
successCallbackLink: String
}
type CredentialsRequirementType {
password: PasswordLimitsType!
}
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
@@ -110,13 +114,10 @@ type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addToEarlyAccess(email: String!): Int!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!): UserSubscription!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!): UserType!
"""Create a subscription checkout link of stripe"""
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
"""Create a subscription checkout link of stripe"""
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
@@ -137,7 +138,7 @@ type Mutation {
removeAvatar: RemoveAvatar!
removeEarlyAccess(email: String!): Int!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
resumeSubscription(idempotencyKey: String!): UserSubscription!
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
revoke(userId: String!, workspaceId: String!): Boolean!
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
@@ -152,7 +153,7 @@ type Mutation {
signIn(email: String!, password: String!): UserType!
signUp(email: String!, name: String!, password: String!): UserType!
updateProfile(input: UpdateUserInput!): UserType!
updateSubscriptionRecurring(idempotencyKey: String!, recurring: SubscriptionRecurring!): UserSubscription!
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
@@ -167,6 +168,11 @@ enum OAuthProviderType {
Google
}
type PasswordLimitsType {
maxLength: Int!
minLength: Int!
}
"""User permission in workspace"""
enum Permission {
Admin
@@ -239,6 +245,9 @@ type ServerConfigType {
"""server base url"""
baseUrl: String!
"""credentials requirement"""
credentialsRequirement: CredentialsRequirementType!
"""enabled server features"""
features: [ServerFeature!]!
@@ -267,6 +276,7 @@ enum ServerFeature {
}
enum SubscriptionPlan {
AI
Enterprise
Free
Pro
@@ -275,11 +285,11 @@ enum SubscriptionPlan {
}
type SubscriptionPrice {
amount: Int!
amount: Int
currency: String!
plan: SubscriptionPlan!
type: String!
yearlyAmount: Int!
yearlyAmount: Int
}
enum SubscriptionRecurring {
@@ -388,7 +398,8 @@ type UserType {
"""User name"""
name: String!
quota: UserQuota
subscription: UserSubscription
subscription(plan: SubscriptionPlan = Pro): UserSubscription @deprecated(reason: "use `UserType.subscriptions`")
subscriptions: [UserSubscription!]!
token: tokenType! @deprecated(reason: "use [/api/auth/authorize]")
}

View File

@@ -1,5 +1,6 @@
import type { INestApplication } from '@nestjs/common';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { AppModule } from '../src/app.module';

View File

@@ -3,7 +3,8 @@ import {
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../src/core/auth/service';
import { MailService } from '../src/fundamentals/mailer';

View File

@@ -0,0 +1,164 @@
import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { AuthModule, CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { UserModule, UserService } from '../../src/core/user';
import { MailService } from '../../src/fundamentals';
import { createTestingApp, getSession, sessionCookie } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
mailer: Sinon.SinonStubbedInstance<MailService>;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
m.overrideProvider(MailService).useValue(
Sinon.createStubInstance(MailService)
);
},
});
t.context.auth = app.get(AuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.mailer = app.get(MailService);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to sign in with credential', async t => {
const { app, u1 } = t.context;
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: '1' })
.expect(200);
const session = await getSession(app, res);
t.is(session.user!.id, u1.id);
});
test('should be able to sign in with email', async t => {
const { app, u1, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email })
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
let [signInLink] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
signInLink = url.pathname + url.search;
const signInRes = await request(app.getHttpServer())
.get(signInLink)
.expect(302);
const session = await getSession(app, signInRes);
t.is(session.user!.id, u1.id);
});
test('should be able to sign up with email', async t => {
const { app, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignUpMail.resolves({ rejected: [] });
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: 'u2@affine.pro' })
.expect(200);
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
let [signUpLink] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
signUpLink = url.pathname + url.search;
const signInRes = await request(app.getHttpServer())
.get(signUpLink)
.expect(302);
const session = await getSession(app, signInRes);
t.is(session.user!.email, 'u2@affine.pro');
});
test('should not be able to sign in if email is invalid', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: '' })
.expect(400);
t.is(res.body.message, 'Invalid email address');
});
test('should not be able to sign in if forbidden', async t => {
const { app, auth, u1, mailer } = t.context;
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email })
.expect(HttpStatus.PAYMENT_REQUIRED);
t.true(mailer.sendSignInMail.notCalled);
canSignInStub.restore();
});
test('should be able to sign out', async t => {
const { app, u1 } = t.context;
const signInRes = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: '1' })
.expect(200);
const cookie = sessionCookie(signInRes.headers);
await request(app.getHttpServer())
.get('/api/auth/sign-out')
.set('cookie', cookie)
.expect(200);
const session = await getSession(app, signInRes);
t.falsy(session.user);
});
test('should not be able to sign out if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/api/auth/sign-out')
.expect(HttpStatus.UNAUTHORIZED);
t.assert(true);
});

View File

@@ -0,0 +1,131 @@
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import {
AuthGuard,
AuthModule,
CurrentUser,
Public,
} from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { createTestingApp } from '../utils';
@Controller('/')
class TestController {
@Public()
@Get('/public')
home(@CurrentUser() user?: CurrentUser) {
return { user };
}
@Get('/private')
private(@CurrentUser() user: CurrentUser) {
return { user };
}
}
const test = ava as TestFn<{
app: INestApplication;
auth: Sinon.SinonStubbedInstance<AuthService>;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [AuthModule],
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
controllers: [TestController],
tapModule: m => {
m.overrideProvider(AuthService).useValue(
Sinon.createStubInstance(AuthService)
);
},
});
t.context.auth = app.get(AuthService);
t.context.app = app;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to visit public api if not signed in', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer()).get('/public').expect(200);
t.is(res.body.user, undefined);
});
test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ id: '1' });
const res = await request(app.getHttpServer())
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
});
test('should not be able to visit private api if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/private')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
statusCode: 401,
message: 'You are not signed in.',
error: 'Unauthorized',
});
t.assert(true);
});
test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ id: '1' });
const res = await request(app.getHttpServer())
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
});
test('should be able to parse session cookie', async t => {
const { app, auth } = t.context;
await request(app.getHttpServer())
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
});
test('should be able to parse bearer token', async t => {
const { app, auth } = t.context;
await request(app.getHttpServer())
.get('/public')
.auth('1', { type: 'bearer' })
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
});

View File

@@ -0,0 +1,219 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CurrentUser } from '../../src/core/auth';
import { AuthService, parseAuthUserSeqNum } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { UserModule, UserService } from '../../src/core/user';
import { createTestingModule } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
m: TestingModule;
}>;
test.beforeEach(async t => {
const m = await createTestingModule({
imports: [FeatureModule, UserModule],
providers: [AuthService],
});
t.context.auth = m.get(AuthService);
t.context.user = m.get(UserService);
t.context.db = m.get(PrismaClient);
t.context.m = m;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.m.close();
});
test('should be able to parse auth user seq num', t => {
t.deepEqual(
[
'1',
'2',
3,
-3,
'-4',
'1.1',
'str',
'1111111111111111111111111111111111111111111',
].map(parseAuthUserSeqNum),
[1, 2, 3, 0, 0, 0, 0, 0]
);
});
test('should be able to sign up', async t => {
const { auth } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
t.is(u2.email, 'u2@affine.pro');
const signedU2 = await auth.signIn(u2.email, '1');
t.is(u2.email, signedU2.email);
});
test('should throw if email duplicated', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), {
message: 'Email was taken',
});
});
test('should be able to sign in', async t => {
const { auth } = t.context;
const signedInUser = await auth.signIn('u1@affine.pro', '1');
t.is(signedInUser.email, 'u1@affine.pro');
});
test('should throw if user not found', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
message: 'Invalid sign in credentials',
});
});
test('should throw if password not set', async t => {
const { user, auth } = t.context;
await user.createUser({
email: 'u2@affine.pro',
name: 'u2',
});
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
message: 'User Password is not set. Should login through email link.',
});
});
test('should throw if password not match', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), {
message: 'Invalid sign in credentials',
});
});
test('should be able to change password', async t => {
const { auth, u1 } = t.context;
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changePassword(u1.id, '2');
await t.throwsAsync(
() => auth.signIn('u1@affine.pro', '1' /* old password */),
{
message: 'Invalid sign in credentials',
}
);
signedInU1 = await auth.signIn('u1@affine.pro', '2');
t.is(signedInU1.email, u1.email);
});
test('should be able to change email', async t => {
const { auth, u1 } = t.context;
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changeEmail(u1.id, 'u2@affine.pro');
await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), {
message: 'Invalid sign in credentials',
});
signedInU1 = await auth.signIn('u2@affine.pro', '1');
t.is(signedInU1.email, 'u2@affine.pro');
});
// Tests for Session
test('should be able to create user session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
t.is(session.userId, u1.id);
});
test('should be able to get user from session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const user = await auth.getUser(session.sessionId);
t.not(user, null);
t.is(user!.id, u1.id);
});
test('should be able to sign out session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
});
// Tests for Multi-Accounts Session
test('should be able to sign in different user in a same session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
const [signedU1, signedU2] = await auth.getUserList(session.sessionId);
t.not(signedU1, null);
t.not(signedU2, null);
t.is(signedU1!.id, u1.id);
t.is(signedU2!.id, u2.id);
});
test('should be able to signout multi accounts session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
// sign out user at seq(0)
let signedOutSession = await auth.signOut(session.sessionId);
t.not(signedOutSession, null);
const signedU2 = await auth.getUser(session.sessionId, 0);
const noUser = await auth.getUser(session.sessionId, 1);
t.is(noUser, null);
t.not(signedU2, null);
t.is(signedU2!.id, u2.id);
// sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
const noUser2 = await auth.getUser(session.sessionId, 0);
t.is(noUser2, null);
});

View File

@@ -0,0 +1,93 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { TokenService, TokenType } from '../../src/core/auth';
import { createTestingModule } from '../utils';
const test = ava as TestFn<{
ts: TokenService;
m: TestingModule;
}>;
test.beforeEach(async t => {
const m = await createTestingModule({
providers: [TokenService],
});
t.context.ts = m.get(TokenService);
t.context.m = m;
});
test.afterEach.always(async t => {
await t.context.m.close();
});
test('should be able to create token', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
t.truthy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
test('should fail the verification if the token is invalid', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
// wrong type
t.falsy(
await ts.verifyToken(TokenType.ChangeEmail, token, {
credential: 'user@affine.pro',
})
);
// no credential
t.falsy(await ts.verifyToken(TokenType.SignIn, token));
// wrong credential
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'wrong@affine.pro',
})
);
});
test('should fail if the token expired', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
await t.context.m.get(PrismaClient).verificationToken.updateMany({
data: {
expiresAt: new Date(Date.now() - 1000),
},
});
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
test('should be able to verify only once', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
t.truthy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
// will be invalid after the first time of verification
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});

View File

@@ -127,7 +127,7 @@ test('should merge update when intervel due', async t => {
await manager.autoSquash();
t.deepEqual(
(await manager.getBinary(ws.id, '1'))?.toString('hex'),
(await manager.getBinary(ws.id, '1'))?.binary.toString('hex'),
Buffer.from(update.buffer).toString('hex')
);
@@ -150,7 +150,7 @@ test('should merge update when intervel due', async t => {
await manager.autoSquash();
t.deepEqual(
(await manager.getBinary(ws.id, '1'))?.toString('hex'),
(await manager.getBinary(ws.id, '1'))?.binary.toString('hex'),
Buffer.from(encodeStateAsUpdate(doc)).toString('hex')
);
});
@@ -275,20 +275,21 @@ test('should throw if meet max retry times', async t => {
test('should be able to insert the snapshot if it is new created', async t => {
const manager = m.get(DocManager);
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
await manager.push('1', '1', Buffer.from(update));
{
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
await manager.push('1', '1', Buffer.from(update));
}
const updates = await manager.getUpdates('1', '1');
t.is(updates.length, 1);
// @ts-expect-error private
const snapshot = await manager.squash(null, updates);
const { doc } = await manager.squash(null, updates);
t.truthy(snapshot);
t.is(snapshot.getText('content').toString(), 'hello');
t.truthy(doc);
t.is(doc.getText('content').toString(), 'hello');
const restUpdates = await manager.getUpdates('1', '1');
@@ -315,14 +316,14 @@ test('should be able to merge updates into snapshot', async t => {
{
await manager.batchPush('1', '1', updates.slice(0, 2));
// do the merge
const doc = (await manager.get('1', '1'))!;
const { doc } = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'helloworld');
}
{
await manager.batchPush('1', '1', updates.slice(2));
const doc = (await manager.get('1', '1'))!;
const { doc } = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'hello world!');
}
@@ -372,7 +373,7 @@ test('should not update snapshot if doc is outdated', async t => {
const updateRecords = await manager.getUpdates('2', '1');
// @ts-expect-error private
const doc = await manager.squash(snapshot, updateRecords);
const { doc } = await manager.squash(snapshot, updateRecords);
// all updated will merged into doc not matter it's timestamp is outdated or not,
// but the snapshot record will not be updated

View File

@@ -2,7 +2,8 @@
import { INestApplication, Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../src/core/auth/service';
import {

View File

@@ -7,7 +7,7 @@ import * as Sinon from 'sinon';
import { DocHistoryManager } from '../src/core/doc';
import { QuotaModule } from '../src/core/quota';
import { StorageModule } from '../src/core/storage';
import { type EventPayload } from '../src/fundamentals/event';
import type { EventPayload } from '../src/fundamentals/event';
import { createTestingModule } from './utils';
let m: TestingModule;

View File

@@ -7,7 +7,8 @@ import {
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import { TestingModule } from '@nestjs/testing';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../src/core/auth/service';
import { ConfigModule } from '../src/fundamentals/config';

View File

@@ -1,5 +1,6 @@
import type { INestApplication } from '@nestjs/common';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../src/app.module';

View File

@@ -0,0 +1,345 @@
import '../../src/plugins/config';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
import { CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { UserService } from '../../src/core/user';
import { Config, ConfigModule } from '../../src/fundamentals/config';
import { GoogleOAuthProvider } from '../../src/plugins/oauth/providers/google';
import { OAuthService } from '../../src/plugins/oauth/service';
import { OAuthProviderName } from '../../src/plugins/oauth/types';
import { createTestingApp, getSession } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
oauth: OAuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [
ConfigModule.forRoot({
plugins: {
oauth: {
providers: {
google: {
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
},
},
},
},
}),
AppModule,
],
});
t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test("should be able to redirect to oauth provider's login page", async t => {
const { app } = t.context;
const res = await request(app.getHttpServer())
.get('/oauth/login?provider=Google')
.expect(HttpStatus.FOUND);
const redirect = new URL(res.header.location);
t.is(redirect.origin, 'https://accounts.google.com');
t.is(redirect.pathname, '/o/oauth2/v2/auth');
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
t.is(
redirect.searchParams.get('redirect_uri'),
app.get(Config).baseUrl + '/oauth/callback'
);
t.is(redirect.searchParams.get('response_type'), 'code');
t.is(redirect.searchParams.get('prompt'), 'select_account');
t.truthy(redirect.searchParams.get('state'));
});
test('should throw if provider is invalid', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/login?provider=Invalid')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid OAuth provider',
error: 'Bad Request',
});
t.assert(true);
});
test('should be able to save oauth state', async t => {
const { oauth } = t.context;
const id = await oauth.saveOAuthState({
redirectUri: 'https://example.com',
provider: OAuthProviderName.Google,
});
const state = await oauth.getOAuthState(id);
t.truthy(state);
t.is(state!.provider, OAuthProviderName.Google);
t.is(state!.redirectUri, 'https://example.com');
});
test('should be able to get registered oauth providers', async t => {
const { oauth } = t.context;
const providers = oauth.availableOAuthProviders();
t.deepEqual(providers, [OAuthProviderName.Google]);
});
test('should throw if code is missing in callback uri', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Missing query parameter `code`',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if state is missing in callback uri', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback?code=1')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid callback state parameter',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if state is expired', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback?code=1&state=1')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'OAuth state expired, please try again.',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if provider is missing in state', async t => {
const { app, oauth } = t.context;
// @ts-expect-error mock
Sinon.stub(oauth, 'getOAuthState').resolves({});
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Missing callback state parameter `provider`',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if provider is invalid in callback uri', async t => {
const { app, oauth } = t.context;
// @ts-expect-error mock
Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' });
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid provider',
error: 'Bad Request',
});
t.assert(true);
});
function mockOAuthProvider(app: INestApplication, email: string) {
const provider = app.get(GoogleOAuthProvider);
const oauth = app.get(OAuthService);
Sinon.stub(oauth, 'getOAuthState').resolves({
provider: OAuthProviderName.Google,
redirectUri: '/',
});
// @ts-expect-error mock
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
Sinon.stub(provider, 'getUser').resolves({
id: '1',
email,
avatarUrl: 'avatar',
});
}
test('should be able to sign up with oauth', async t => {
const { app, db } = t.context;
mockOAuthProvider(app, 'u2@affine.pro');
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const session = await getSession(app, res);
t.truthy(session.user);
t.is(session.user!.email, 'u2@affine.pro');
const user = await db.user.findFirst({
select: {
email: true,
connectedAccounts: true,
},
where: {
email: 'u2@affine.pro',
},
});
t.truthy(user);
t.is(user!.email, 'u2@affine.pro');
t.is(user!.connectedAccounts[0].providerAccountId, '1');
});
test('should throw if account register in another way', async t => {
const { app, u1 } = t.context;
mockOAuthProvider(app, u1.email);
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const link = new URL(res.headers.location);
t.is(link.pathname, '/signIn');
t.is(
link.searchParams.get('error'),
'The account with provided email is not register in the same way.'
);
});
test('should be able to fullfil user with oauth sign in', async t => {
const { app, user, db } = t.context;
const u3 = await user.createUser({
name: 'u3',
email: 'u3@affine.pro',
registered: false,
});
mockOAuthProvider(app, u3.email);
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const session = await getSession(app, res);
t.truthy(session.user);
t.is(session.user!.email, u3.email);
const account = await db.connectedAccount.findFirst({
where: {
userId: u3.id,
},
});
t.truthy(account);
});
test('should throw if oauth account already connected', async t => {
const { app, db, u1, auth } = t.context;
await db.connectedAccount.create({
data: {
userId: u1.id,
provider: OAuthProviderName.Google,
providerAccountId: '1',
},
});
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ id: 'u2-id' });
mockOAuthProvider(app, 'u2@affine.pro');
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.FOUND);
const link = new URL(res.headers.location);
t.is(link.pathname, '/signIn');
t.is(
link.searchParams.get('error'),
'The third-party account has already been connected to another user.'
);
});
test('should be able to connect oauth account', async t => {
const { app, u1, auth, db } = t.context;
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ id: u1.id });
mockOAuthProvider(app, u1.email);
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.FOUND);
const account = await db.connectedAccount.findFirst({
where: {
userId: u1.id,
},
});
t.truthy(account);
t.is(account!.userId, u1.id);
});

View File

@@ -1,7 +1,8 @@
/// <reference types="../src/global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../src/core/auth';
import {

View File

@@ -1,11 +1,40 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import request, { type Response } from 'supertest';
import type { ClientTokenType } from '../../src/core/auth';
import {
AuthService,
type ClientTokenType,
type CurrentUser,
} from '../../src/core/auth';
import type { UserType } from '../../src/core/user';
import { gql } from './common';
export function sessionCookie(headers: any) {
const cookie = headers['set-cookie']?.find((c: string) =>
c.startsWith(`${AuthService.sessionCookieName}=`)
);
if (!cookie) {
return null;
}
return cookie.split(';')[0];
}
export async function getSession(
app: INestApplication,
signInRes: Response
): Promise<{ user?: CurrentUser }> {
const cookie = sessionCookie(signInRes.headers);
const res = await request(app.getHttpServer())
.get('/api/auth/session')
.set('cookie', cookie)
.expect(200);
return res.body;
}
export async function signUp(
app: INestApplication,
name: string,

View File

@@ -113,6 +113,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
cors: true,
bodyParser: true,
rawBody: true,
logger: ['warn'],
});
app.use(

View File

@@ -4,10 +4,12 @@ import {
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AppModule } from '../src/app.module';
import { AuthService } from '../src/core/auth/service';
import { UserService } from '../src/core/user';
import { MailService } from '../src/fundamentals/mailer';
import {
acceptInviteById,
@@ -25,6 +27,7 @@ const test = ava as TestFn<{
client: PrismaClient;
auth: AuthService;
mail: MailService;
user: UserService;
}>;
test.beforeEach(async t => {
@@ -35,6 +38,7 @@ test.beforeEach(async t => {
t.context.client = app.get(PrismaClient);
t.context.auth = app.get(AuthService);
t.context.mail = app.get(MailService);
t.context.user = app.get(UserService);
});
test.afterEach.always(async t => {
@@ -95,16 +99,16 @@ test('should revoke a user', async t => {
});
test('should create user if not exist', async t => {
const { app, auth } = t.context;
const { app, user } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin');
const user = await auth.getUserByEmail('u2@affine.pro');
t.not(user, undefined, 'failed to create user');
t.is(user?.name, 'u2', 'failed to create user');
const u2 = await user.findUserByEmail('u2@affine.pro');
t.not(u2, undefined, 'failed to create user');
t.is(u2?.name, 'u2', 'failed to create user');
});
test('should invite a user by link', async t => {

View File

@@ -1,6 +1,7 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { type TestFn } from 'ava';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { AppModule } from '../src/app.module';

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/storage",
"version": "0.12.0",
"version": "0.14.0",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@types/debug": "^4.1.12",
"vitest": "1.3.1"
"vitest": "1.4.0"
},
"version": "0.12.0"
"version": "0.14.0"
}

View File

@@ -3,11 +3,11 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.13.0-canary-202403140735-2367cd5",
"@blocksuite/store": "0.13.0-canary-202403140735-2367cd5",
"@blocksuite/global": "0.14.0-canary-202403250855-4171ecd",
"@blocksuite/store": "0.14.0-canary-202403250855-4171ecd",
"react": "18.2.0",
"react-dom": "18.2.0",
"vitest": "1.3.1"
"vitest": "1.4.0"
},
"exports": {
"./automation": "./src/automation.ts",
@@ -26,5 +26,5 @@
"lit": "^3.1.2",
"zod": "^3.22.4"
},
"version": "0.12.0"
"version": "0.14.0"
}

View File

@@ -53,6 +53,8 @@ export const collectionSchema = z.object({
name: z.string(),
filterList: z.array(filterSchema),
allowList: z.array(z.string()),
createDate: z.union([z.date(), z.number()]).optional(),
updateDate: z.union([z.date(), z.number()]).optional(),
});
export const deletedCollectionSchema = z.object({
userId: z.string().optional(),
@@ -78,6 +80,8 @@ export const tagSchema = z.object({
value: z.string(),
color: z.string(),
parentId: z.string().optional(),
createDate: z.union([z.date(), z.number()]).optional(),
updateDate: z.union([z.date(), z.number()]).optional(),
});
export type Tag = z.input<typeof tagSchema>;

View File

@@ -19,7 +19,6 @@ export const runtimeFlagsSchema = z.object({
enableNewSettingModal: z.boolean(),
enableNewSettingUnstableApi: z.boolean(),
enableSQLiteProvider: z.boolean(),
enableNotificationCenter: z.boolean(),
enableCloud: z.boolean(),
enableCaptcha: z.boolean(),
enableEnhanceShareMode: z.boolean(),

View File

@@ -6,9 +6,5 @@
"noEmit": false,
"outDir": "lib"
},
"references": [
{
"path": "../../../tests/fixtures"
}
]
"references": []
}

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