Compare commits

..

145 Commits

Author SHA1 Message Date
EYHN
cd56d8a6e6 feat(core): new onboarding template (hotfix) (#5952) 2024-02-29 14:01:31 +08:00
liuyi
6ed5ec36bb fix(server): sender passed to nextauth is never used (#5938) 2024-02-28 14:57:23 +08:00
liuyi
0fa917aabb ci: fix selfhost (#5920)
enhancement

___

- Introduced a new ESM module resolution setup using `ts-node` to enhance the development and deployment process.
- Implemented a dynamic loader script registration mechanism to facilitate ESM module loading.
- Simplified the predeploy script execution by refining environment variable handling and stdout configuration.
- Updated `package.json` to reflect changes in script commands for better ESM support and added necessary dependencies for `ts-node` and `typescript`.

___

<table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
  <td>
    <details>
      <summary><strong>loader.js</strong><dd><code>Introduce ESM Module Resolution via ts-node</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

packages/backend/server/scripts/loader.js

<li>Introduced <code>ts-node</code> configuration for ESM module resolution.<br> <li> Exported a <code>resolve</code> function for module resolution.<br>

</details>

  </td>
  <td><a href="https:/toeverything/AFFiNE/pull/5920/files#diff-9ed793897a493633028d510db0742ff38d2d86471c54b17513d4354c51597ef8">+11/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>register.js</strong><dd><code>Implement Dynamic Loader Script Registration</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

packages/backend/server/scripts/register.js

<li>Implemented dynamic registration of the loader script.<br> <li> Utilized <code>node:module</code> and <code>node:url</code> for script registration.<br>

</details>

  </td>
  <td><a href="https:/toeverything/AFFiNE/pull/5920/files#diff-64831012a09f2bc4bc5a611ddb8e0871b0e83588de6c5d4f2f5cb1dae8fff244">+4/-0</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>self-host-predeploy.js</strong><dd><code>Simplify Predeploy Script Execution</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

packages/backend/server/scripts/self-host-predeploy.js

<li>Simplified environment variable passing to <code>execSync</code>.<br> <li> Changed stdout handling to inherit from the parent process.<br>

</details>

  </td>
  <td><a href="https:/toeverything/AFFiNE/pull/5920/files#diff-bd7b0be14c198018c21dadda6945a779c57d13e4c8584ee62da4baa99d370664">+3/-5</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>package.json</strong><dd><code>Update Scripts and Dependencies for ESM Support</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

packages/backend/server/package.json

<li>Updated script commands for ESM compatibility.<br> <li> Added <code>ts-node</code> and <code>typescript</code> dependencies.<br> <li> Removed redundant <code>--es-module-specifier-resolution=node</code> flags.<br>

</details>

  </td>
  <td><a href="https:/toeverything/AFFiNE/pull/5920/files#diff-a6530c6fe539aaa49ff0a7a80bc4362c1d95c419fdd19125415dcc869b31a443">+6/-6</a>&nbsp; &nbsp; &nbsp; </td>

</tr>
</table></td></tr></tr></tbody></table>

___

>  **PR-Agent usage**:
>Comment `/help` on the PR to get a list of all available PR-Agent tools and their descriptions
2024-02-28 14:56:37 +08:00
liuyi
eb79e6bdc9 ci: fix canary deployment (#5851) 2024-02-28 12:13:03 +08:00
liuyi
cf52a43773 feat(server): allow customize mailer server (#5835) 2024-02-28 12:12:06 +08:00
LongYinan
9203980a8c build: fix selfhost config 2024-02-27 23:49:25 +08:00
liuyi
d34eb2cbe5 fix(server): apply env overrides after all config merged (#5795) 2024-02-27 21:46:08 +08:00
liuyi
7f3f993ce4 refactor(server): reorganize server configs (#5753) 2024-02-27 21:45:26 +08:00
Peng Xiao
df17001284 feat(core): add shortcut for openning settings (#5883)
fix https://github.com/toeverything/AFFiNE/issues/5881
2024-02-23 15:14:39 +08:00
Peng Xiao
e400abf1f4 fix: keyboard shortcut style in cmdk (#5882)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7eb2fa93-675a-43a6-8db4-9681fbbd1406.png)
2024-02-23 15:14:39 +08:00
EYHN
640aa00148 fix(core): fix app boot speed (#5884) 2024-02-23 06:54:42 +00:00
Ayush Agrawal
5ae8f029f7 chore: bump blocksuite (#5868)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-22 17:15:27 +08:00
EYHN
a26e0b3ec9 fix(core): disable sidebar user select (#5862)
close #5846
2024-02-22 17:15:26 +08:00
Umar Faiz
f492b6711b fix(core): the pitch zooming function incorrectly zooms the toolbar (#5456)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-22 17:15:26 +08:00
EYHN
81aae61394 fix(core): fix desktop e2e (#5867) 2024-02-22 16:12:23 +08:00
Peng Xiao
e08f58beea chore: bump electron dependencies (#5770)
to include this fix https://github.com/electron/electron/pull/40994
2024-02-22 15:26:09 +08:00
EYHN
4560819f76 fix(core): fix 404 after signout (hotfix) (#5865) 2024-02-22 15:11:11 +08:00
liuyi
193c197a54 fix(core): window.open to a new origin will be blocked by browser (#5856) 2024-02-21 20:51:25 +08:00
LongYinan
449c0a38a7 fix(electron): linux AppImage output path 2024-02-21 15:48:38 +08:00
Ayush Agrawal
8d141e5a81 feat: blocksuite integration for pageMode & pageUpdatedAt (#5849)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-21 15:16:49 +08:00
Lye Hongtao
e04911315f feat: move templates into AFFiNE (#5750)
Related to https://github.com/toeverything/blocksuite/pull/6156

### Change
Move the edgeless templates to AFFiNE. All templates are stored as zip files. Run `node build-edgeless.mjs` in `@affine/templates` to generate JSON-format templates and importing script. The template will be generated automatically during building and dev (`yarn dev`).
2024-02-21 15:10:47 +08:00
Ayush Agrawal
75d58679b6 chore: bump blocksuite (#5852) 2024-02-21 15:10:35 +08:00
Ayush Agrawal
2e6386e4cf feat: bump blocksuite (#5845) 2024-02-21 15:09:33 +08:00
Ayush Agrawal
f345a61df0 feat: bump blocksuite (#5817) 2024-02-21 15:01:45 +08:00
Flrande
a6420fcd76 feat: bump blocksuite (#5812) 2024-02-21 15:00:43 +08:00
Yifeng Wang
fec406f7e8 feat: bump blocksuite (#5767)
Co-authored-by: LongYinan <lynweklm@gmail.com>
2024-02-21 14:57:05 +08:00
LongYinan
769398591b fix: resolve deps and types issues after cherry-pick 2024-02-21 14:51:29 +08:00
JimmFly
e01569fff7 feat(core): add loading to quick search modal (#5785)
close AFF-285

add `useSyncEngineStatus` hooks
add loading style

<img width="977" alt="test1" src="https://github.com/toeverything/AFFiNE/assets/102217452/e8bf6714-e42b-4adf-a279-341ef5f5cfc0">
2024-02-21 14:40:30 +08:00
JimmFly
6bde2de783 feat(core): add starAFFiNE and issueFeedback modal (#5718)
close TOV-482

https://github.com/toeverything/AFFiNE/assets/102217452/da1f74bc-4b8d-4d7f-987d-f53da98d92fe
2024-02-21 14:23:09 +08:00
JimmFly
3513ced6cb fix(core): match page preview and page title in page list (#5840)
close TOV-578
2024-02-21 14:21:05 +08:00
Adithyan
8dc9addc40 feat: Duplicate page in page list and clone naming improvements (#5818) 2024-02-21 14:20:38 +08:00
Muhammad Arsil
9d9f89ef2e fix: cards overlapping issue (#5727)
Co-authored-by: EYHN <cneyhn@gmail.com>
2024-02-21 14:20:31 +08:00
DarkSky
6cfe5d4566 feat: use custom verify token policy (#5836) 2024-02-21 14:20:08 +08:00
Peng Xiao
6032b432f8 build(electron): generate latest-linux.yml (#5822) 2024-02-21 14:19:37 +08:00
Peng Xiao
5823787ded fix(electron): linux login issues (#5821)
Looks like there are some issues using `@reforged/maker-appimage`:
- deep link not working properly (cannot login)
- cannot be installed via AppImageLauncher, which is required to enable deep link on linux

I forked saleae/electron-forge-maker-appimage into https://github.com/toeverything/electron-forge-maker-appimage to fix these issues
See changes: https://github.com/saleae/electron-forge-maker-appimage/compare/master...toeverything:electron-forge-maker-appimage:master?w=1

To enable login on Linux, the app must be installed via AppImageLauncher.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/28dcaadb-49d1-4c95-8a4f-ef41bef604be.png)

fix https://github.com/toeverything/AFFiNE/issues/4978
fix https://github.com/toeverything/AFFiNE/issues/4466
2024-02-21 14:19:23 +08:00
LongYinan
b3f272ba70 fix: selfhost build (#5833) 2024-02-21 14:18:05 +08:00
Whitewater
a5df5a7c8a chore: skip sync when offline (#5786) 2024-02-21 14:17:20 +08:00
DarkSky
90de90403a feat: refresh new workspace feature (#5834) 2024-02-20 16:12:35 +08:00
李华桥
4d4e4fc4e2 Merge branch 'stable' into beta 2024-02-20 15:52:22 +08:00
liuyi
aa73e532d3 fix(server): doc upsert without row lock (#5765) 2024-02-16 22:06:23 +08:00
liuyi
31faa93c71 chore(storage): bump y-octo (#5751) 2024-02-16 19:36:26 +08:00
liuyi
def60f4c61 fix(server): doc upsert without row lock (#5765) 2024-02-05 16:39:48 +08:00
EYHN
d15ec0ff77 fix(workspace): fix sync handshake (hot-fix) (#5797) 2024-02-05 10:56:46 +08:00
EYHN
d2acd0385a fix(core): prevent data loss (hot-fix) (#5798) 2024-02-05 10:54:51 +08:00
EYHN
1effb2f25f fix(workspace): fix sync stuck (#5762) (#5772) 2024-02-01 17:41:49 +08:00
Joooye_34
9189d26332 feat: support sign-in with subscription coupon (#5768) 2024-02-01 17:03:29 +08:00
liuyi
79a8be7799 feat(server): allow pass coupon to checkout session (#5749) 2024-02-01 17:03:16 +08:00
liuyi
1a643cc70c fix(server): doc upsert race condition (#5755) 2024-01-31 21:36:35 +08:00
liuyi
9321be3ff5 fix(server): doc upsert race condition (#5755) 2024-01-31 11:08:52 +00:00
李华桥
24dc3f95ff fix: consume blob stream correctly (#5706) 2024-01-31 11:38:40 +08:00
Cats Juice
4257b5f3a4 fix(core): set createDate to journal's date when journal created (#5701) 2024-01-30 23:13:02 +08:00
Cats Juice
ea17e86032 feat(core): ignore empty journals for page lists (#5744) 2024-01-30 17:21:20 +08:00
Joooye_34
48cd8999bd fix: static resource not found in web server (#5745) 2024-01-30 17:21:05 +08:00
李华桥
cdf1d9002e Merge branch 'canary' into stable 2024-01-29 17:53:10 +08:00
李华桥
79b39f14d2 Merge branch 'canary' into stable 2024-01-25 13:46:21 +08:00
李华桥
619420cfd1 chore: recover yarn.lock 2024-01-25 00:38:29 +08:00
李华桥
739e914b5f Merge branch 'canary' into stable 2024-01-25 00:33:28 +08:00
liuyi
5e9739eb3a fix(server): del staled update count cache if unmatch (#5674) 2024-01-23 16:55:49 +08:00
liuyi
0a89b7f528 fix(server): standalone early access users detection (#5601) 2024-01-16 11:39:36 +08:00
DarkSky
0a0ee37ac2 fix: return empty resp if user not exists in login preflight (#5588) 2024-01-13 23:30:01 +08:00
Peng Xiao
a143379161 fix(electron): remove cors headers hack (#5581) 2024-01-12 16:49:16 +08:00
regischen
8e7dedfe82 feat: bump blocksuite (#5575) 2024-01-12 12:43:56 +08:00
EYHN
d25a8547d0 refactor(core): move page list to core (#5556) 2024-01-12 12:43:45 +08:00
Peng Xiao
4d16229fea chore(core): remove affine/cmdk package (#5552)
patch cmdk based on https://github.com/pengx17/cmdk/tree/patch-1
fix https://github.com/toeverything/AFFiNE/issues/5548
2024-01-12 12:43:35 +08:00
EYHN
99371be7e8 fix(core): workspace not found after import (#5571)
close TOV-393
2024-01-12 11:05:59 +08:00
李华桥
34ed8dd7a5 Merge branch 'canary' into stable 2024-01-10 10:59:28 +08:00
李华桥
39b7b671b1 Merge branch 'canary' into stable 2024-01-09 19:44:52 +08:00
李华桥
207b56d5af Merge branch 'canary' into stable 2024-01-09 17:16:17 +08:00
DarkSky
9e94e7195b fix: use absolute path in gql client (#5454) (#5462) 2023-12-29 16:02:29 +08:00
Peng Xiao
de951c8779 fix(core): enable page history for beta/stable (#5415) 2023-12-27 14:39:59 +08:00
EYHN
fd37026ca5 fix(component): fix font display on safari (#5393)
before

![CleanShot 2023-12-25 at 13.09.26.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/4fe08951-67bb-4050-ba14-94391db1cac1.png)

after

![CleanShot 2023-12-25 at 13.09.13.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/fbfb17ec-b871-4746-9d3c-d24f850ecca1.png)
2023-12-27 14:39:50 +08:00
JimmFly
4fd5812a89 fix(core): avatars are not aligned (#5404) 2023-12-26 20:43:08 +08:00
Peng Xiao
d01e987ecc fix(core): trash page footer display issue (#5402)
Before

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/eb5e5b18-c4a2-469b-8763-be34c39ba736.png)

After

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7b3ef339-0cb5-44fe-9e75-cec0e97d28b7.png)
2023-12-26 20:42:54 +08:00
Joooye_34
d87c218c0b fix(electron): set stable base url to app.affine.pro (#5401)
close TOV-282
2023-12-26 20:42:41 +08:00
Peng Xiao
a5bf5cc244 fix(core): about setting blink issue (#5399) 2023-12-26 20:42:33 +08:00
Peng Xiao
16bcd6e76b fix(core): workpace list blink issue on open (#5400) 2023-12-26 20:42:19 +08:00
JimmFly
2e2ace8472 chore(core): add background color to questionnaire (#5396) 2023-12-26 20:42:06 +08:00
Cats Juice
37cff8fe8d fix(core): correct title of onboarding article-2 (#5387) 2023-12-26 20:41:58 +08:00
DarkSky
70ab3b4916 fix: use prefix in electron to prevent formdata bug (#5395) 2023-12-26 20:41:47 +08:00
EYHN
f42ba54578 fix(core): fix flickering workspace list (#5391) 2023-12-26 20:41:36 +08:00
EYHN
a67c8181fc fix(workspace): fix svg file with xml header (#5388) 2023-12-26 20:41:28 +08:00
regischen
613efbded9 feat: bump blocksuite (#5386) 2023-12-26 20:41:18 +08:00
李华桥
549419d102 Merge branch 'canary' into stable 2023-12-22 16:29:51 +08:00
李华桥
21c42f8771 Merge branch 'canary' into stable 2023-12-22 01:29:30 +08:00
李华桥
9012adda7a Merge branch 'canary' into stable 2023-12-21 18:42:56 +08:00
李华桥
fb442e9055 Merge branch 'canary' into stable 2023-12-21 16:22:57 +08:00
李华桥
a231474dd2 Merge branch 'canary' into stable 2023-12-21 14:26:01 +08:00
李华桥
833b42000b Merge branch 'canary' into stable 2023-12-20 16:36:44 +08:00
李华桥
7690c48710 Merge branch 'canary' into stable 2023-12-20 16:32:36 +08:00
DarkSky
579828a700 fix: use secure websocket (#5297) 2023-12-13 22:28:04 +08:00
DarkSky
746db2ccfc feat: only follow serverUrlPrefix at redirect to client (#5295) 2023-12-13 20:37:20 +08:00
李华桥
eff344a9c1 Merge branch 'canary' into stable 2023-12-12 16:45:47 +08:00
李华桥
c89ebab596 Merge branch 'canary' into stable 2023-12-12 11:04:33 +08:00
liuyi
62f4421b7c fix(server): avoid updates persist forever (#5258) 2023-12-11 17:42:25 +08:00
李华桥
42383dbd29 Merge branch 'canary' into stable 2023-12-10 21:04:15 +08:00
李华桥
120e7397ba Merge branch 'canary' into stable 2023-12-01 16:12:17 +08:00
李华桥
24123ad01c Revert "Revert "Merge remote-tracking branch 'origin/canary' into stable""
This reverts commit 89197bacef.
2023-12-01 13:29:43 +08:00
李华桥
ad50320391 v0.10.3 2023-12-01 12:52:15 +08:00
李华桥
eb21a60dda v0.10.3-beta.7 2023-12-01 12:12:20 +08:00
Joooye_34
c0e3be2d40 fix(core): rerender error boundary when route change and improve sentry report (#5147) 2023-12-01 04:04:44 +00:00
李华桥
09d3b72358 v0.10.3-beta.6 2023-11-30 23:02:26 +08:00
Joooye_34
246e16c6c0 fix(infra): compatibility logic follow blocksuite (#5143) 2023-11-30 23:01:38 +08:00
李华桥
dc279d062b v0.10.3-beta.5 2023-11-30 16:49:55 +08:00
Joooye_34
47d5f9e1c2 fix(infra): use blocksuite api to check compatibility (#5137) 2023-11-30 08:48:13 +00:00
Joooye_34
a226eb8d5f fix(core): expose catched editor load error (#5133) 2023-11-29 20:31:35 +08:00
Joooye_34
908c4e1a6f ci: add sentry env when frontend assets build (#5131) 2023-11-29 10:03:49 +00:00
李华桥
1d0bcc80a0 v0.10.3-beta.4 2023-11-29 16:14:06 +08:00
Joooye_34
50010bd824 fix(core): implement editor timeout and report error from boundary (#5105) 2023-11-29 08:10:38 +00:00
liuyi
c0ede1326d fix(server): wrong OTEL config (#5084) 2023-11-29 11:19:13 +08:00
李华桥
89197bacef Revert "Merge remote-tracking branch 'origin/canary' into stable"
This reverts commit 992ed89a89, reversing
changes made to d272d7922d.
2023-11-29 11:18:45 +08:00
李华桥
f97d323ab5 Revert "Revert "refactor(server): standarderlize metrics and trace with OTEL (#5054)""
This reverts commit c1cd1713b9.
2023-11-29 11:07:28 +08:00
EYHN
2acb219dcc fix(workspace): filter awareness from other workspace (#5093) 2023-11-28 16:47:45 +08:00
LongYinan
992ed89a89 Merge remote-tracking branch 'origin/canary' into stable 2023-11-28 15:12:52 +08:00
李华桥
d272d7922d v0.10.3-beta.2 2023-11-25 23:50:40 +08:00
李华桥
c1cd1713b9 Revert "refactor(server): standarderlize metrics and trace with OTEL (#5054)"
This reverts commit 91efca107a.
2023-11-25 23:50:39 +08:00
李华桥
b20e91bee0 v0.10.3-beta.1 2023-11-25 14:14:40 +08:00
李华桥
9a4e5ec8c3 Merge branch 'canary' into stable 2023-11-25 14:14:14 +08:00
李华桥
2019838ae7 v0.10.3-beta.0 2023-11-24 11:39:23 +08:00
李华桥
30ff25f400 Merge branch 'canary' into stable 2023-11-23 23:40:32 +08:00
李华桥
e766208c18 chore: reset merge wrong codes 2023-11-23 22:53:06 +08:00
李华桥
8742f28148 Merge branch 'canary' into stable 2023-11-23 21:31:42 +08:00
LongYinan
cd291bb60e build: remove useless source-map-loader to speedup webpack (#4910) 2023-11-20 10:52:28 +08:00
LongYinan
62c0efcfd1 fix(core): handle the getSession network error properly (#4909)
If network offline or API error happens, the `session` returned by the `useSession` hook will be null, so we can't assume it is not null.

There should be following changes:
1. create a page in ErrorBoundary to let the user refetch the session.
2. The `SessionProvider` stop to pull the new session once the session is null, we need to figure out a way to pull the new session when the network is back or the user click the refetch button.
2023-11-17 16:50:48 +08:00
liuyi
87248b3337 fix(server): all viewers can share public link (#4968) 2023-11-17 12:34:15 +08:00
Joooye_34
00c940f7df chore: bump affine version to 0.10.2 (#4959) 2023-11-16 15:48:37 +08:00
Flrande
931b459fbd chore: bump blocksuite (#4958) 2023-11-16 14:27:39 +08:00
LongYinan
51e71f4a0a ci: prevent error if rust build is cached by nx (#4951)
If Rust build was cached by nx, only the output file will be presented. The chmod command will be failed in this case like: https://github.com/toeverything/AFFiNE/actions/runs/6874496337/job/18697360212
2023-11-16 10:31:51 +08:00
Peng Xiao
9b631f2328 fix(infra): page id compat fix for page ids in workspace.meta (#4950)
since we strip `page:` in keys of workspacedoc.spaces, we should also strip the prefix in meta.pages as well.
2023-11-15 17:36:08 +08:00
LongYinan
01f481a9b6 ci: only disable postinstall on macOS in nightly desktop build (#4938) 2023-11-14 23:00:30 +08:00
Joooye_34
0177ab5c87 fix(infra): workspace migration without blockVersions (#4936) 2023-11-14 14:38:11 +01:00
Peng Xiao
4db35d341c perf(component): use png instead of svg for rendering noise svg (#4935) 2023-11-14 11:52:51 +00:00
DarkSky
3c4a803c97 fix: change password token check (#4934) (#4932) 2023-11-14 11:15:54 +00:00
LongYinan
05154dc7ca ci: disable postinstall in nightly desktop build (#4930)
Should be part of https://github.com/toeverything/AFFiNE/pull/4885
2023-11-14 14:13:55 +08:00
Peng Xiao
c90b477f60 fix(core): change server url of stable to insider (#4902) (#4926) 2023-11-14 12:05:52 +08:00
李华桥
6f18ddbe85 v0.10.1 2023-11-13 19:49:26 +08:00
LongYinan
dde779a71d test(e2e): add subdoc migration test (#4921)
test(e2e): add subdoc migration test

fix: remove .only
2023-11-13 18:00:40 +08:00
Peng Xiao
bd9f66fbc7 fix(infra): compatibility fix for space prefix (#4912)
It seems there are some cases that [this upstream PR](https://github.com/toeverything/blocksuite/pull/4747) will cause data loss.

Because of some historical reasons, the page id could be different with its doc id.
It might be caused by subdoc migration in the following (not 100% sure if all white screen issue is caused by it) 0714c12703/packages/common/infra/src/blocksuite/index.ts (L538-L540)

In version 0.10, page id in spaces no longer has prefix "space:"
The data flow for fetching a doc's updates is:
- page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
- because of guid logic change, the doc that previously prefixed with `space:` will not be found in `doc.spaces`
- when fetching the rows of this doc using the doc id === page id,
  it will return EMPTY since there is no updates associated with the page id

The provided fix in the PR will patch the `spaces` field of the root doc so that after 0.10 the page doc can still be found in the `spaces` map. It shall apply to both of the idb & sqlite datasources.

Special thanks to @lawvs 's db file for investigation!
2023-11-13 17:57:56 +08:00
liuyi
92f1f40bfa fix(server): wrap updates applying in a transaction (#4922) 2023-11-13 08:49:30 +00:00
LongYinan
48dc1049b3 Merge pull request #4913 from toeverything/darksky/cleanup-depolyment
chore: cleanup deployment
2023-11-12 11:20:02 +08:00
DarkSky
9add530370 chore: cleanup deployment 2023-11-12 11:03:25 +08:00
LongYinan
b77460d871 Merge pull request #4908 from toeverything/61/hotfix-websocket-payload
fix(server): increase server acceptable websocket payload size
2023-11-10 22:01:48 +08:00
forehalo
42db41776b fix(server): increase server acceptable websocket payload size 2023-11-10 21:31:45 +08:00
李华桥
075439c74f fix(core): change server url of stable to insider 2023-11-10 18:32:53 +08:00
Yifeng Wang
fc6c553ece chore: bump theme (#4904)
Co-authored-by: 李华桥 <joooye1991@gmail.com>
2023-11-10 15:40:38 +08:00
Joooye_34
59cb3d5df1 fix(core): change server url of stable to insider (#4902) 2023-11-10 14:50:57 +08:00
952 changed files with 23083 additions and 35971 deletions

View File

@@ -8,11 +8,5 @@ corepack prepare yarn@stable --activate
# install dependencies
yarn install
# Build Server Dependencies
yarn workspace @affine/storage build
# Create database
yarn workspace @affine/server prisma db push
# Create user username: affine, password: affine
echo "INSERT INTO \"users\"(\"id\",\"name\",\"email\",\"email_verified\",\"created_at\",\"password\") VALUES('99f3ad04-7c9b-441e-a6db-79f73aa64db9','affine','affine@affine.pro','2024-02-26 15:54:16.974','2024-02-26 15:54:16.974+00','\$argon2id\$v=19\$m=19456,t=2,p=1\$esDS3QCHRH0Kmeh87YPm5Q\$9S+jf+xzw2Hicj6nkWltvaaaXX3dQIxAFwCfFa9o38A');" | yarn workspace @affine/server prisma db execute --stdin
yarn workspace @affine/server prisma db push

View File

@@ -21,6 +21,5 @@
}
},
"updateContentCommand": "bash ./.devcontainer/build.sh",
"postCreateCommand": "bash ./.devcontainer/setup-user.sh",
"postStartCommand": ["yarn dev", "yarn workspace @affine/server dev"]
"postCreateCommand": "bash ./.devcontainer/setup-user.sh"
}

View File

@@ -1,9 +1,7 @@
set -e
if [ -v GRAPHITE_TOKEN ];then
gt auth --token $GRAPHITE_TOKEN
fi
git fetch origin canary:canary --depth=1
git fetch
git branch canary -t origin/canary
gt init --trunk canary

View File

@@ -31,6 +31,17 @@ const createPattern = packageName => [
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['next-auth/react'],
message: "Import hooks from 'use-current-user.tsx'",
// useSession is type unsafe
importNames: ['useSession'],
},
{
group: ['next-auth/react'],
message: "Import hooks from 'cloud-utils.ts'",
importNames: ['signIn', 'signOut'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',
@@ -53,7 +64,7 @@ const allPackages = [
'packages/frontend/i18n',
'packages/frontend/native',
'packages/frontend/templates',
'packages/frontend/workspace-impl',
'packages/frontend/workspace',
'packages/common/debug',
'packages/common/env',
'packages/common/infra',
@@ -168,6 +179,17 @@ const config = {
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['next-auth/react'],
message: "Import hooks from 'use-current-user.tsx'",
// useSession is type unsafe
importNames: ['useSession'],
},
{
group: ['next-auth/react'],
message: "Import hooks from 'cloud-utils.ts'",
importNames: ['signIn', 'signOut'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',

View File

@@ -7,8 +7,6 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Check out this [link](https://github.com/toeverything/AFFiNE/blob/canary/docs/issue-triaging.md)
to learn how we manage issues and when your issue will be processed.
- type: textarea
id: what-happened
attributes:
@@ -43,14 +41,6 @@ body:
- Firefox
- Safari
- Other
- type: checkboxes
id: selfhost
attributes:
label: Are you self-hosting?
description: >
If you are self-hosting, please check the box and provide information about your setup.
options:
- label: 'Yes'
- type: textarea
id: logs
attributes:
@@ -63,3 +53,11 @@ body:
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images here
- type: checkboxes
attributes:
label: Are you willing to submit a PR?
description: >
(Optional) We encourage you to submit a [Pull Request](https://github.com/toeverything/affine/pulls) (PR) to help improve AFFiNE for everyone, especially if you have a good understanding of how to implement a fix or feature.
See the AFFiNE [Contributing Guide](https://github.com/toeverything/affine/blob/canary/CONTRIBUTING.md) to get started.
options:
- label: Yes I'd like to help by submitting a PR!

View File

@@ -49,7 +49,7 @@ runs:
- name: Build
shell: bash
run: |
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} -- --target ${{ inputs.target }} --use-napi-cross
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} --target ${{ inputs.target }} --use-napi-cross
env:
NX_CLOUD_ACCESS_TOKEN: ${{ inputs.nx_token }}
DEBUG: 'napi:*'

View File

@@ -24,7 +24,7 @@ runs:
shell: bash
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
- uses: azure/setup-helm@v4
- uses: azure/setup-helm@v3
- id: auth
uses: google-github-actions/auth@v2
with:

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,9 +111,8 @@ 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=true`,
`--set graphql.app.features.earlyAccessPreview=false`,
`--set graphql.app.features.syncClientVersionCheck=true`,
`--set sync.replicaCount=${syncReplicaCount}`,
`--set-string sync.image.tag="${imageTag}"`,
...serviceAnnotations,

View File

@@ -1,4 +1,4 @@
FROM openresty/openresty:1.25.3.1-0-buster
FROM openresty/openresty:1.21.4.3-0-buster
WORKDIR /app
COPY ./packages/frontend/core/dist ./dist
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf

View File

@@ -1,4 +1,4 @@
FROM node:20-bookworm-slim
FROM node:18-bookworm-slim
COPY ./packages/backend/server /app
COPY ./packages/frontend/core/dist /app/static

View File

@@ -60,7 +60,6 @@ app:
webhookKey: ''
features:
earlyAccessPreview: false
syncClientVersionCheck: false
serviceAccount:
create: true

View File

@@ -60,13 +60,6 @@ spec:
name: affine-graphql
port:
number: {{ .Values.graphql.service.port }}
- path: /oauth
pathType: Prefix
backend:
service:
name: affine-graphql
port:
number: {{ .Values.graphql.service.port }}
- path: /
pathType: Prefix
backend:

5
.github/labeler.yml vendored
View File

@@ -29,6 +29,11 @@ mod:plugin-cli:
- any-glob-to-any-file:
- 'tools/plugin-cli/**/*'
mod:workspace:
- changed-files:
- any-glob-to-any-file:
- 'packages/common/workspace/**/*'
mod:workspace-impl:
- changed-files:
- any-glob-to-any-file:

View File

@@ -46,11 +46,6 @@
"rangeStrategy": "replace",
"groupName": "electron-forge"
},
{
"matchPackageNames": ["oxlint"],
"rangeStrategy": "replace",
"groupName": "oxlint"
},
{
"groupName": "blocksuite-canary",
"matchPackagePatterns": ["^@blocksuite"],
@@ -62,7 +57,7 @@
"groupName": "all non-major dependencies",
"groupSlug": "all-minor-patch",
"matchPackagePatterns": ["*"],
"excludePackagePatterns": ["^@blocksuite/", "oxlint"],
"excludePackagePatterns": ["^@blocksuite/"],
"matchUpdateTypes": ["minor", "patch"]
},
{

View File

@@ -4,8 +4,6 @@ on:
push:
branches:
- canary
- beta
- stable
- v[0-9]+.[0-9]+.x-staging
- v[0-9]+.[0-9]+.x
paths-ignore:
@@ -118,7 +116,6 @@ jobs:
runs-on: ubuntu-latest
env:
DISTRIBUTION: browser
IN_CI_TEST: true
strategy:
fail-fast: false
matrix:
@@ -193,7 +190,7 @@ jobs:
run: yarn nx test:coverage @affine/monorepo
- name: Upload unit test coverage results
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./.coverage/store/lcov.info
@@ -212,8 +209,8 @@ jobs:
spec:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: windows-latest, target: x86_64-pc-windows-msvc }
- { os: macos-14, target: x86_64-apple-darwin }
- { os: macos-14, target: aarch64-apple-darwin }
- { os: macos-latest, target: x86_64-apple-darwin }
- { os: macos-latest, target: aarch64-apple-darwin }
steps:
- uses: actions/checkout@v4
@@ -336,11 +333,17 @@ jobs:
env:
PGPASSWORD: affine
- name: Run init-db script
- name: Generate prisma client
run: |
yarn workspace @affine/server exec prisma generate
yarn workspace @affine/server exec prisma db push
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script
run: |
yarn workspace @affine/server data-migration run
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -351,7 +354,7 @@ jobs:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Upload server test coverage results
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/backend/server/.coverage/lcov.info
@@ -365,7 +368,6 @@ jobs:
env:
DISTRIBUTION: browser
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
IN_CI_TEST: true
strategy:
fail-fast: false
matrix:
@@ -429,11 +431,17 @@ jobs:
env:
PGPASSWORD: affine
- name: Run init-db script
- name: Generate prisma client
run: |
yarn workspace @affine/server exec prisma generate
yarn workspace @affine/server exec prisma db push
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script
run: |
yarn workspace @affine/server data-migration run
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
- name: ${{ matrix.tests.name }}
run: |
@@ -454,21 +462,22 @@ jobs:
runs-on: ${{ matrix.spec.os }}
strategy:
fail-fast: false
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
matrix:
spec:
- {
os: macos-14,
os: macos-latest,
platform: macos,
arch: x64,
target: x86_64-apple-darwin,
test: false,
test: true,
}
- {
os: macos-14,
os: macos-latest,
platform: macos,
arch: arm64,
target: aarch64-apple-darwin,
test: true,
test: false,
}
- {
os: ubuntu-latest,
@@ -525,7 +534,7 @@ jobs:
run: yarn workspace @affine/electron build
- name: Run desktop tests
if: ${{ matrix.spec.os == 'ubuntu-latest' }}
if: ${{ matrix.spec.test && matrix.spec.os == 'ubuntu-latest' }}
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn workspace @affine-test/affine-desktop e2e
- name: Run desktop tests
@@ -533,23 +542,15 @@ jobs:
run: yarn workspace @affine-test/affine-desktop e2e
- name: Make bundle
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }}
env:
SKIP_BUNDLE: true
SKIP_WEB_BUILD: true
HOIST_NODE_MODULES: 1
run: yarn workspace @affine/electron package --platform=darwin --arch=arm64
- name: Make AppImage
run: yarn workspace @affine/electron make --platform=linux --arch=x64
if: ${{ matrix.spec.target == 'x86_64-unknown-linux-gnu' }}
env:
SKIP_PLUGIN_BUILD: 1
SKIP_WEB_BUILD: 1
HOIST_NODE_MODULES: 1
- name: Output check
if: ${{ matrix.spec.os == 'macos-14' && matrix.spec.arch == 'arm64' }}
if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }}
run: |
yarn workspace @affine/electron exec node --loader ts-node/esm/transpile-only ./scripts/macos-arm64-output-check.ts
@@ -560,22 +561,3 @@ jobs:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
test-done:
needs:
- analyze
- lint
- check-yarn-binary
- e2e-test
- e2e-migration-test
- unit-test
- server-test
- server-e2e-test
- desktop-test
if: always()
runs-on: ubuntu-latest
name: 3, 2, 1 Launch
steps:
- run: exit 1
# Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}

View File

@@ -7,11 +7,6 @@ on:
schedule:
- cron: '0 9 * * *'
permissions:
contents: write
pull-requests: write
actions: write
jobs:
dispatch-deploy:
runs-on: ubuntu-latest

View File

@@ -72,7 +72,7 @@ jobs:
if-no-files-found: error
build-core-selfhost:
name: Build @affine/core selfhost
name: Build @affine/core
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
@@ -88,7 +88,6 @@ jobs:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: false
PUBLIC_PATH: '/'
SELF_HOSTED: true
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload core artifact
@@ -137,10 +136,6 @@ jobs:
build-docker:
name: Build Docker
runs-on: ubuntu-latest
permissions:
contents: 'write'
id-token: 'write'
packages: 'write'
needs:
- build-server
- build-core
@@ -295,7 +290,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

@@ -24,7 +24,7 @@ jobs:
token: ${{ secrets.HELM_RELEASER_TOKEN }}
- name: Install Helm
uses: azure/setup-helm@v4
uses: azure/setup-helm@v3
- name: Install chart releaser
run: |

View File

@@ -9,4 +9,4 @@ jobs:
add-reviews:
runs-on: ubuntu-latest
steps:
- uses: kentaro-m/auto-assign-action@v2.0.0
- uses: kentaro-m/auto-assign-action@v1.2.5

View File

@@ -7,11 +7,6 @@ on:
schedule:
- cron: '0 9 * * *'
permissions:
contents: write
pull-requests: write
actions: write
jobs:
dispatch-release-desktop:
runs-on: ubuntu-latest

View File

@@ -68,13 +68,15 @@ jobs:
make-distribution:
strategy:
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
# For windows, we need a separate approach
matrix:
spec:
- runner: macos-14
- runner: macos-latest
platform: darwin
arch: x64
target: x86_64-apple-darwin
- runner: macos-14
- runner: macos-latest
platform: darwin
arch: arm64
target: aarch64-apple-darwin
@@ -130,23 +132,18 @@ jobs:
SKIP_WEB_BUILD: 1
HOIST_NODE_MODULES: 1
- name: signing DMG
if: ${{ matrix.spec.platform == 'darwin' }}
run: |
codesign --force --sign "Developer ID Application: TOEVERYTHING PTE. LTD." packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/AFFiNE.dmg
- name: Save artifacts (mac)
if: ${{ matrix.spec.platform == 'darwin' }}
run: |
mkdir -p builds
mv packages/frontend/electron/out/*/make/*.dmg ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
mv packages/frontend/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
mv packages/frontend/electron/out/*/make/*.dmg ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
mv packages/frontend/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
- name: Save artifacts (linux)
if: ${{ matrix.spec.platform == 'linux' }}
run: |
mkdir -p builds
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-x64.zip
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-x64.appimage
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
- name: Upload Artifact
uses: actions/upload-artifact@v4
@@ -156,6 +153,8 @@ jobs:
package-distribution-windows:
strategy:
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
# For windows, we need a separate approach
matrix:
spec:
- runner: windows-latest
@@ -229,6 +228,8 @@ jobs:
make-windows-installer:
needs: sign-packaged-artifacts-windows
strategy:
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
# For windows, we need a separate approach
matrix:
spec:
- runner: windows-latest
@@ -278,8 +279,10 @@ jobs:
artifact-name: installer-win32-x64
finalize-installer-windows:
needs: [sign-installer-artifacts-windows, before-make]
needs: sign-installer-artifacts-windows
strategy:
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
# For windows, we need a separate approach
matrix:
spec:
- runner: windows-latest
@@ -299,9 +302,9 @@ jobs:
- name: Save artifacts
run: |
mkdir -p builds
mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.zip
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.exe
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.msi
mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.zip
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.exe
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.msi
- name: Upload Artifact
uses: actions/upload-artifact@v4
@@ -351,7 +354,7 @@ jobs:
RELEASE_VERSION: ${{ needs.before-make.outputs.RELEASE_VERSION }}
- name: Create Release Draft
if: ${{ github.ref_type == 'tag' }}
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
name: ${{ needs.before-make.outputs.RELEASE_VERSION }}
body: ''
@@ -362,12 +365,12 @@ jobs:
./*.zip
./*.dmg
./*.exe
./*.appimage
./*.AppImage
./*.apk
./*.yml
- name: Create Nightly Release Draft
if: ${{ github.ref_type == 'branch' }}
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
@@ -384,6 +387,6 @@ jobs:
./*.zip
./*.dmg
./*.exe
./*.appimage
./*.AppImage
./*.apk
./*.yml

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Publish
uses: cloudflare/wrangler-action@v3.4.1
uses: cloudflare/wrangler-action@v3.4.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}

2
.nvmrc
View File

@@ -1 +1 @@
20
18

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,4 +12,4 @@ npmPublishAccess: public
npmPublishRegistry: "https://registry.npmjs.org"
yarnPath: .yarn/releases/yarn-4.1.1.cjs
yarnPath: .yarn/releases/yarn-4.0.2.cjs

4
Cargo.lock generated
View File

@@ -949,9 +949,9 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.11"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0"
dependencies = [
"libc",
"log",

130
README.md
View File

@@ -5,89 +5,86 @@
Write, Draw and Plan All at Once
<br>
</h1>
<a href="https://affine.pro/download">
<img alt="affine logo" src="https://cdn.affine.pro/Github_hero_image1.png" style="width: 100%">
</a>
<br/>
<p align="center">
A privacy-focussed, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
One hyper-fused platform for wildly creative minds.
<p>
One hyper-fused platform for wildly creative minds. <br />
A privacy-focussed, local-first, open-source, and ready-to-use alternative for Notion & Miro.
</p>
<br/>
<br/>
<a href="https://www.producthunt.com/posts/affine-3?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-affine&#0045;3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=440671&theme=light" alt="AFFiNE - One&#0032;app&#0032;for&#0032;all&#0032;&#0045;&#0032;Where&#0032;Notion&#0032;meets&#0032;Miro | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<br/>
<br/>
</div>
<div align="center">
<a href="https://affine.pro">Home Page</a> |
<a href="https://discord.com/invite/yz6tGVsf5p">Discord</a> |
<a href="https://app.affine.pro">Live Demo</a> |
<a href="https://affine.pro/blog/">Blog</a> |
<a href="https://docs.affine.pro/docs/">Documentation</a>
</div>
<br/>
[![AFFiNE Web](<https://img.shields.io/badge/-Try%20It%20Online%20%E2%86%92-rgb(84,56,255)?style=flat-square&logoColor=white&logo=affine>)](https://app.affine.pro)
[![AFFiNE macOS M1/M2 Chip](https://img.shields.io/badge/-macOS_M_Chip%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://affine.pro/download)
[![AFFiNE macOS x64](https://img.shields.io/badge/-macOS_x86%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://affine.pro/download)
[![AFFiNE Window x64](https://img.shields.io/badge/-Windows%20%E2%86%92-blue?style=flat-square&logo=windows&logoColor=white)](https://affine.pro/download)
[![AFFiNE Linux](https://img.shields.io/badge/-Linux%20%E2%86%92-yellow?style=flat-square&logo=linux&logoColor=white)](https://affine.pro/download)
[![Releases](https://img.shields.io/github/downloads/toeverything/AFFiNE/total)](https://github.com/toeverything/AFFiNE/releases/latest)
[![stars-icon]](https://github.com/toeverything/AFFiNE)
[![All Contributors][all-contributors-badge]](#contributors)
[![codecov]](https://codecov.io/gh/toeverything/AFFiNE)
[![Node-version-icon]](https://nodejs.org/)
[![TypeScript-version-icon]](https://www.typescriptlang.org/)
[![React-version-icon]](https://reactjs.org/)
[![blocksuite-icon]](https://github.com/toeverything/blocksuite)
[![Rust-version-icon]](https://www.rust-lang.org/)
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftoeverything%2FAFFiNE.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftoeverything%2FAFFiNE?ref=badge_shield)
[![Deploy](https://github.com/toeverything/AFFiNE/actions/workflows/deploy.yml/badge.svg)](https://github.com/toeverything/AFFiNE/actions/workflows/deploy.yml)
</div>
---
<div align="center">
<a href="http://affine.pro"><img src="https://img.shields.io/badge/-AFFiNE-06449d?style=social&logo=affine" height=25></a>
&nbsp;
<a href="https://community.affine.pro"><img src="https://img.shields.io/badge/-Community-424549?style=social&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAXNJREFUWEftlitLRUEURtdVEVExWUx2qxgNVouoXYtNDP4Tw20WtftAsItZrHaTYBJREZ98MAc248wcZxi4CGfSeezHmm/23kyPAa/egPPTAXQK/FsFBP7ldVDRZoqcgO9I+2bHy3ZIJBfTCPCZM1tqAxwBmzUBrNQNbEx+5b0B5oEN4NCBrAMnMaiUAuPAs3HU82TLEZwBqwGbaJ4UgKQ8CFR6SoEl4LIWwCJwZQCegKkWBWLHVKSActvdzgG3DqitDf3/VQBskBDALrDnAKXUo3ueAF5KinAf2DKOmnzD7l214bdbA6hC1XHZNQa8hSBC0hwDa57xDHDvvvWB7ciOZoE79+8CWPbsBGc769eFxJdWIKcuyIdRoG3W7AAC1dJkHDIOo8B78+4rEBo8r4AkLFk6Jk3HaeDBBTgHVmIAfpJUz+cAFXVBreQCvQYW/lqEjV1NAMUMqpAaxQMHyDnjYtuS+0BxstwaqJooFqxToFPgB5FuPCEB6XK2AAAAAElFTkSuQmCC" height=25></a>
&nbsp;
<a href="https://discord.com/invite/yz6tGVsf5p"><img src="https://img.shields.io/badge/-Discord-424549?style=social&logo=discord" height=25></a>
&nbsp;
<a href="https://t.me/affineworkos"><img src="https://img.shields.io/badge/-Telegram-red?style=social&logo=telegram" height=25></a>
&nbsp;
<a href="https://twitter.com/AffineOfficial"><img src="https://img.shields.io/badge/-Twitter-red?style=social&logo=twitter" height=25></a>
&nbsp;
<a href="https://medium.com/@affineworkos"><img src="https://img.shields.io/badge/-Medium-red?style=social&logo=medium" height=25></a>
</div>
<br />
<div align="center">
<em>Docs, canvas and tables are hyper-merged with AFFiNE - just like the word affine (əˈɪn | a-fine).</em>
</div>
<br />
<div align="center">
<img src="https://github.com/toeverything/AFFiNE/assets/79301703/49a426bb-8d2b-4216-891a-fa5993642253" style="width: 100%"/>
</div>
![img_v2_37a7cc04-ab3f-4405-ae9a-f84ceb4c948g](https://user-images.githubusercontent.com/79301703/230892907-5fd5c0c5-1665-4d75-8a35-744e0afc36a5.gif)
## Join our community
Before we tell you how to get started with AFFiNE, we'd like to shamelessly plug our awesome user and developer communities across [official social platforms](https://community.affine.pro/c/start-here/)! Once youre familiar with using the software, maybe you will share your wisdom with others and even consider joining the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador) to help spread AFFiNE to the world.
## Getting started & staying tuned with us.
⚠️ Please note that AFFiNE is still under active development and is not yet ready for production use. ⚠️
[![affine.pro](https://img.shields.io/static/v1?label=Try%20it%20Online&logo=affine&message=%E2%86%92&style=for-the-badge)](https://app.affine.pro) No installation or registration required! Head over to our website and try it out now.
[![community.affine.pro](https://img.shields.io/static/v1?label=Join%20the%20community&message=%E2%86%92&style=for-the-badge)](https://community.affine.pro) Our wonderful community, where you can meet and engage with the team, developers and other like-minded enthusiastic user of AFFiNE.
Star us, and you will receive all releases notifications from GitHub without any delay!
<img src="https://user-images.githubusercontent.com/79301703/230891830-0110681e-8c7e-483b-b6d9-9e42b291b9ef.gif" style="width: 100%"/>
## What is AFFiNE
AFFiNE is an open-source, all-in-one workspace and an operating system for all the building blocks that assemble your knowledge base and much more -- wiki, knowledge management, presentation and digital assets. It's a better alternative to Notion and Miro.
![rbU3YmmsQT](https://user-images.githubusercontent.com/79301703/230891830-0110681e-8c7e-483b-b6d9-9e42b291b9ef.gif)
## Features
**A true canvas for blocks in any form. Docs and whiteboard are now fully merged.**
- **Hyper merged** — Write, draw and plan all at once. Assemble any blocks you love on any canvas you like to enjoy seamless transitions between workflows with AFFiNE.
- **Privacy focussed** — AFFiNE is built with your privacy in mind and is one of our key concerns. We want you to keep control of your data, allowing you to store it as you like, where you like while still being able to freely edit and view your data on-demand.
- **Offline-first** — With your privacy in mind we also decided to go offline-first. This means that AFFiNE can be used offline, whether you want to view or edit, with support for conflict-free merging when you are back online.
- **Clean, intuitive design** — With AFFiNE you can concentrate on editing with a clean and modern interface. Which is responsive, so it looks great on tablets too, and mobile support is coming in the future.
- **Modern Block Editor with Markdown support** — A modern block editor can help you not only for docs, but slides and tables as well. When you write in AFFiNE you can use Markdown syntax which helps create an easier editing experience, that can be experienced with just a keyboard. And this allows you to export your data cleanly into Markdown.
- **Collaboration** — Whether you want to collaborate with yourself across multiple devices, or work together with others, support for collaboration and multiplayer is out-of-the-box, which makes it easy for teams to get started with AFFiNE.
- **Choice of multiple languages** — Thanks to community contributions AFFiNE offers support for multiple languages. If you don't find your language or would like to suggest some changes we welcome your contributions.
- Many editor apps claim to be a canvas for productivity, but AFFiNE is one of the very few which allows you to put any building block on an edgeless canvas -- rich text, sticky notes, any embedded web pages, multi-view databases, linked pages, shapes and even slides. We have it all.
**Multimodal AI partner ready to kick in any work**
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or....draw and code prototype apps and web pages directly all with one prompt? With you, AFFiNE AI pushes your creativity to the edge of your imagination.
**Local-first & Real-time collaborative**
- We love the idea of local-first that you always own your data on your disk, in spite of the cloud. Furthermore, AFFiNE supports real-time sync and collaborations on web and cross-platform clients.
**Self-host & Shape your own AFFiNE**
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks is coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
## Acknowledgement
“We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us along the way, e.g.:
- Quip & Notion with their great concept of “everything is a block”
- Trello with their Kanban
- Airtable & Miro with their no-code programable datasheets
- Miro & Whimiscal with their edgeless visual whiteboard
- Remnote & Capacities with their object-based tag system
There is a large overlap of their atomic “building blocks” between these apps. They are not open source, nor do they have a plugin system like Vscode for contributors to customize. We want to have something that contains all the features we love and also goes one step even further.
Thanks for checking us out, we appreciate your interest and sincerely hope that AFFiNE resonates with you! 🎵 Checking https://affine.pro/ for more details ions.
![img_v2_3a4ee0da-6dd7-48cb-8f19-5411f86768ag](https://user-images.githubusercontent.com/79301703/230893796-dc707955-e4e5-4a42-a3c9-18d1ea754f6f.gif)
## Contributing
@@ -120,7 +117,7 @@ If you have questions, you are welcome to contact us. One of the best places to
We would also like to give thanks to open-source projects that make AFFiNE possible:
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
- [blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync.
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
@@ -143,11 +140,20 @@ We would like to express our gratitude to all the individuals who have already c
## Self-Host
Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiNE. Our team is diligently updating to the latest version. For more information on how to self-host AFFiNE, please refer to our [documentation](https://docs.affine.pro/docs/self-host-affine-).
> We know that the self-host version has been out of date for a long time.
>
> We are working hard to get this updated to the latest version, you can try our desktop version first.
Get started with Docker and deploy your own feature-rich, restriction-free deployment of AFFiNE.
We are working hard to get this updated to the latest version, you can keep an eye on the [latest packages].
## Hiring
Some amazing companies including AFFiNE are looking for developers! Are you interesgo to iour discord channel AFFiNE and/or its partners? Check out some of the latest [jobs available].
Some amazing companies including AFFiNE are looking for developers! Are you interested in helping build with AFFiNE and/or its partners? Check out some of the latest [jobs available].
## Upgrading
For upgrading information, please see our [update page].
## Feature Request
@@ -179,6 +185,8 @@ Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testi
See [LICENSE] for details.
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftoeverything%2FAFFiNE.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftoeverything%2FAFFiNE?ref=badge_large)
[all-contributors-badge]: https://img.shields.io/github/contributors/toeverything/AFFiNE
[license]: ./LICENSE
[building.md]: ./docs/BUILDING.md
@@ -186,7 +194,7 @@ See [LICENSE] for details.
[jobs available]: ./docs/jobs.md
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
[rust-version-icon]: https://img.shields.io/badge/Rust-1.76.0-dea584
[rust-version-icon]: https://img.shields.io/badge/Rust-1.75.0-dea584
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success

View File

@@ -1,29 +0,0 @@
# Security Policy
## Supported Versions
We recommend users to always use the latest major version. Security updates will be provided for the current major version until the next major version is released.
| Version | Supported |
| --------------- | ------------------ |
| 0.12.x (stable) | :white_check_mark: |
| < 0.12.x | :x: |
## Reporting a Vulnerability
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info). We expect your report to contain at least the following for us to evaluate and reproduce:
1. Using platform and version, for example:
- macos arm64 0.12.0-canary-202402220729-0868ac6
- app.affine.pro 0.12.0-canary-202402220729-0868ac6
2. A sets of video or screenshot containing the reproduce steps that proves you successfully exploited the vulnerability, preferably including the time and software version of the successful exploit.
3. Your classification or analysis of the vulnerability (optional)
Since we are an open source project, we also welcome you to provide corresponding fix PRs.
We will provide bounties for vulnerabilities involving user information leakage, permission leakage, and unauthorized code execution. For other types of vulnerabilities, we will determine specific rewards based on the evaluation results.
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.

View File

@@ -29,7 +29,7 @@ It includes the global constants, browser and system check.
This package should be imported at the very beginning of the entry point.
### `@affine/workspace-impl`
### `@affine/workspace`
Current we have two workspace plugin:

View File

@@ -49,20 +49,22 @@ postgres=# \du
### Set the following config to `packages/backend/server/.env`
In the following setup, we assume you have postgres server running at localhost:5432 and mailhog running at localhost:1025.
When logging in via email, you will see the mail arriving at localhost:8025 in a browser.
```
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
NEXTAUTH_URL="http://localhost:8080/"
```
You may need additional env for auth login. You may want to put your own one if you are not part of the AFFiNE team
For email login & password, please refer to https://nodemailer.com/usage/using-gmail/
```
MAILER_SENDER=
MAILER_USER=
MAILER_PASSWORD=
OAUTH_GOOGLE_ENABLED="true"
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
```
## Prepare prisma

View File

@@ -7,9 +7,9 @@
"dev": "nodemon --exec 'typedoc --options ../../typedoc.json' & serve dist/"
},
"devDependencies": {
"nodemon": "^3.1.0",
"nodemon": "^3.0.1",
"serve": "^14.2.1",
"typedoc": "^0.25.8"
"typedoc": "^0.25.4"
},
"nodemonConfig": {
"watch": [

View File

@@ -14,7 +14,7 @@
"tests/affine-legacy/*"
],
"engines": {
"node": "<21.0.0"
"node": ">=18.16.1 <19.0.0"
},
"scripts": {
"dev": "dev-core",
@@ -36,7 +36,7 @@
"test": "vitest --run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc -b tsconfig.json",
"typecheck": "tsc -b tsconfig.json --diagnostics",
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install",
"prepare": "husky"
},
@@ -56,60 +56,61 @@
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/cli": "workspace:*",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@faker-js/faker": "^8.4.1",
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@faker-js/faker": "^8.3.1",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.5.0",
"@nx/vite": "18.0.8",
"@playwright/test": "^1.41.2",
"@taplo/cli": "^0.7.0",
"@testing-library/react": "^14.2.1",
"@nx/vite": "17.2.8",
"@playwright/test": "^1.41.0",
"@taplo/cli": "^0.5.2",
"@testing-library/react": "^14.1.2",
"@toeverything/infra": "workspace:*",
"@types/affine__env": "workspace:*",
"@types/eslint": "^8.56.3",
"@types/node": "^20.11.20",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@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",
"electron": "^29.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-i": "^2.29.1",
"@types/eslint": "^8.44.7",
"@types/node": "^20.9.3",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"@vanilla-extract/vite-plugin": "^3.9.2",
"@vanilla-extract/webpack-plugin": "^2.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/coverage-istanbul": "1.1.3",
"@vitest/ui": "1.1.3",
"electron": "^28.2.1",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-i": "^2.29.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"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",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-sonarjs": "^0.23.0",
"eslint-plugin-unicorn": "^50.0.0",
"eslint-plugin-unused-imports": "^3.0.0",
"eslint-plugin-vue": "^9.18.1",
"fake-indexeddb": "5.0.2",
"happy-dom": "^13.4.1",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"msw": "^2.2.1",
"nanoid": "^5.0.6",
"nx": "^18.0.4",
"happy-dom": "^13.0.0",
"husky": "^9.0.6",
"lint-staged": "^15.1.0",
"msw": "^2.0.8",
"nanoid": "^5.0.3",
"nx": "^17.2.8",
"nyc": "^15.1.0",
"oxlint": "0.0.22",
"prettier": "^3.2.5",
"semver": "^7.6.0",
"prettier": "^3.1.0",
"semver": "^7.5.4",
"serve": "^14.2.1",
"string-width": "^7.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-istanbul": "^6.0.0",
"vite-plugin-static-copy": "^1.0.1",
"vitest": "1.3.1",
"string-width": "^7.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.3.2",
"vite": "^5.0.6",
"vite-plugin-istanbul": "^5.0.0",
"vite-plugin-static-copy": "^1.0.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "1.1.3",
"vitest-fetch-mock": "^0.2.2",
"vitest-mock-extended": "^1.3.1"
},
"packageManager": "yarn@4.1.1",
"packageManager": "yarn@4.0.2",
"resolutions": {
"vite": "^5.0.6",
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
@@ -167,8 +168,9 @@
"unbox-primitive": "npm:@nolyfill/unbox-primitive@latest",
"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",
"next-auth@^4.24.5": "patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch",
"@reforged/maker-appimage/@electron-forge/maker-base": "7.2.0",
"macos-alias": "npm:macos-alias-building@latest",
"fs-xattr": "npm:@napi-rs/xattr@latest",
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
}

View File

@@ -1,70 +0,0 @@
-- DropForeignKey
ALTER TABLE "accounts" DROP CONSTRAINT "accounts_user_id_fkey";
-- DropForeignKey
ALTER TABLE "sessions" DROP CONSTRAINT "sessions_user_id_fkey";
-- CreateTable
CREATE TABLE "user_connected_accounts" (
"id" VARCHAR(36) NOT NULL,
"user_id" VARCHAR(36) NOT NULL,
"provider" VARCHAR NOT NULL,
"provider_account_id" VARCHAR NOT NULL,
"scope" TEXT,
"access_token" TEXT,
"refresh_token" TEXT,
"expires_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "user_connected_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "multiple_users_sessions" (
"id" VARCHAR(36) NOT NULL,
"expires_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "multiple_users_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_sessions" (
"id" VARCHAR(36) NOT NULL,
"session_id" VARCHAR(36) NOT NULL,
"user_id" VARCHAR(36) NOT NULL,
"expires_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification_tokens" (
"token" VARCHAR(36) NOT NULL,
"type" SMALLINT NOT NULL,
"credential" TEXT,
"expiresAt" TIMESTAMPTZ(6) NOT NULL
);
-- CreateIndex
CREATE INDEX "user_connected_accounts_user_id_idx" ON "user_connected_accounts"("user_id");
-- CreateIndex
CREATE INDEX "user_connected_accounts_provider_account_id_idx" ON "user_connected_accounts"("provider_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_sessions_session_id_user_id_key" ON "user_sessions"("session_id", "user_id");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_type_token_key" ON "verification_tokens"("type", "token");
-- AddForeignKey
ALTER TABLE "user_connected_accounts" ADD CONSTRAINT "user_connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "multiple_users_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "registered" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -18,48 +18,48 @@
"predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run"
},
"dependencies": {
"@apollo/server": "^4.10.0",
"@auth/prisma-adapter": "^1.4.0",
"@aws-sdk/client-s3": "^3.515.0",
"@apollo/server": "^4.9.5",
"@auth/prisma-adapter": "^1.0.7",
"@aws-sdk/client-s3": "^3.499.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",
"@keyv/redis": "^2.8.4",
"@nestjs/apollo": "^12.1.0",
"@nestjs/common": "^10.3.3",
"@nestjs/core": "^10.3.3",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/graphql": "^12.1.1",
"@nestjs/platform-express": "^10.3.3",
"@nestjs/platform-socket.io": "^10.3.3",
"@nestjs/schedule": "^4.0.1",
"@nestjs/serve-static": "^4.0.1",
"@keyv/redis": "^2.8.0",
"@nestjs/apollo": "^12.0.11",
"@nestjs/common": "^10.2.10",
"@nestjs/core": "^10.2.10",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/graphql": "^12.0.11",
"@nestjs/platform-express": "^10.2.10",
"@nestjs/platform-socket.io": "^10.2.10",
"@nestjs/schedule": "^4.0.0",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/throttler": "^5.0.1",
"@nestjs/websockets": "^10.3.3",
"@node-rs/argon2": "^1.7.2",
"@node-rs/crc32": "^1.9.2",
"@node-rs/jsonwebtoken": "^0.5.0",
"@nestjs/websockets": "^10.2.10",
"@node-rs/argon2": "^1.5.2",
"@node-rs/crc32": "^1.7.2",
"@node-rs/jsonwebtoken": "^0.3.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/core": "^1.21.0",
"@opentelemetry/exporter-prometheus": "^0.49.0",
"@opentelemetry/exporter-prometheus": "^0.48.0",
"@opentelemetry/exporter-zipkin": "^1.21.0",
"@opentelemetry/host-metrics": "^0.35.0",
"@opentelemetry/instrumentation": "^0.49.0",
"@opentelemetry/instrumentation-graphql": "^0.38.0",
"@opentelemetry/instrumentation-http": "^0.49.0",
"@opentelemetry/instrumentation-ioredis": "^0.38.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.35.0",
"@opentelemetry/instrumentation-socket.io": "^0.37.0",
"@opentelemetry/instrumentation": "^0.48.0",
"@opentelemetry/instrumentation-graphql": "^0.37.0",
"@opentelemetry/instrumentation-http": "^0.48.0",
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-metrics": "^1.21.0",
"@opentelemetry/sdk-node": "^0.49.0",
"@opentelemetry/sdk-node": "^0.48.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"@prisma/client": "^5.10.2",
"@prisma/instrumentation": "^5.10.2",
"@prisma/client": "^5.7.1",
"@prisma/instrumentation": "^5.7.1",
"@socket.io/redis-adapter": "^8.2.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"express": "^4.18.2",
"file-type": "^19.0.0",
@@ -71,49 +71,50 @@
"ioredis": "^5.3.2",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.6",
"nest-commander": "^3.12.5",
"nanoid": "^5.0.3",
"nest-commander": "^3.12.2",
"nestjs-throttler-storage-redis": "^0.4.1",
"nodemailer": "^6.9.10",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.7",
"on-headers": "^1.0.2",
"parse-duration": "^1.1.0",
"pretty-time": "^1.1.0",
"prisma": "^5.10.2",
"prom-client": "^15.1.0",
"reflect-metadata": "^0.2.1",
"prisma": "^5.7.1",
"prom-client": "^15.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"semver": "^7.6.0",
"socket.io": "^4.7.4",
"stripe": "^14.18.0",
"semver": "^7.5.4",
"socket.io": "^4.7.2",
"stripe": "^14.5.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"ws": "^8.16.0",
"yjs": "^13.6.12",
"ws": "^8.14.2",
"yjs": "^13.6.10",
"zod": "^3.22.4"
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/storage": "workspace:*",
"@napi-rs/image": "^1.9.1",
"@nestjs/testing": "^10.3.3",
"@napi-rs/image": "^1.7.0",
"@nestjs/testing": "^10.2.10",
"@types/cookie-parser": "^1.4.6",
"@types/engine.io": "^3.1.10",
"@types/express": "^4.17.21",
"@types/graphql-upload": "^16.0.7",
"@types/graphql-upload": "^16.0.5",
"@types/keyv": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.20",
"@types/lodash-es": "^4.17.11",
"@types/node": "^20.9.3",
"@types/nodemailer": "^6.4.14",
"@types/on-headers": "^1.0.3",
"@types/pretty-time": "^1.1.5",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"@types/sinon": "^17.0.2",
"@types/supertest": "^6.0.0",
"@types/ws": "^8.5.10",
"ava": "^6.1.1",
"c8": "^9.1.0",
"nodemon": "^3.1.0",
"ava": "^6.0.0",
"c8": "^9.0.0",
"nodemon": "^3.0.1",
"sinon": "^17.0.1",
"supertest": "^6.3.4"
"supertest": "^6.3.3"
},
"ava": {
"timeout": "1m",
@@ -125,7 +126,8 @@
"--trace-sigint",
"--loader",
"ts-node/esm/transpile-only.mjs",
"--es-module-specifier-resolution=node"
"--es-module-specifier-resolution",
"node"
],
"files": [
"tests/**/*.spec.ts",
@@ -142,8 +144,7 @@
"MAILER_USER": "noreply@toeverything.info",
"MAILER_PASSWORD": "affine",
"MAILER_SENDER": "noreply@toeverything.info",
"FEATURES_EARLY_ACCESS_PREVIEW": "false",
"DEPLOYMENT_TYPE": "affine"
"FEATURES_EARLY_ACCESS_PREVIEW": "false"
}
},
"nodemonConfig": {
@@ -152,7 +153,8 @@
"nodeArgs": [
"--loader",
"ts-node/esm.mjs",
"--es-module-specifier-resolution=node"
"--es-module-specifier-resolution",
"node"
],
"ignore": [
"**/__tests__/**",

View File

@@ -10,83 +10,28 @@ datasource db {
}
model User {
id String @id @default(uuid()) @db.VarChar
name String
email String @unique
emailVerifiedAt DateTime? @map("email_verified")
avatarUrl String? @map("avatar_url") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
id String @id @default(uuid()) @db.VarChar
name String
email String @unique
emailVerified DateTime? @map("email_verified")
// image field is for the next-auth
avatarUrl String? @map("avatar_url") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
/// Not available if user signed up through OAuth providers
password String? @db.VarChar
/// Indicate whether the user finished the signup progress.
/// for example, the value will be false if user never registered and invited into a workspace by others.
registered Boolean @default(true)
password String? @db.VarChar
accounts Account[]
sessions Session[]
features UserFeatures[]
customer UserStripeCustomer?
subscription UserSubscription?
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
connectedAccounts ConnectedAccount[]
sessions UserSession[]
@@map("users")
}
model ConnectedAccount {
id String @id @default(uuid()) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
provider String @db.VarChar
providerAccountId String @map("provider_account_id") @db.VarChar
scope String? @db.Text
accessToken String? @map("access_token") @db.Text
refreshToken String? @map("refresh_token") @db.Text
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([providerAccountId])
@@map("user_connected_accounts")
}
model Session {
id String @id @default(uuid()) @db.VarChar(36)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
userSessions UserSession[]
@@map("multiple_users_sessions")
}
model UserSession {
id String @id @default(uuid()) @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([sessionId, userId])
@@map("user_sessions")
}
model VerificationToken {
token String @db.VarChar(36)
type Int @db.SmallInt
credential String? @db.Text
expiresAt DateTime @db.Timestamptz(6)
@@unique([type, token])
@@map("verification_tokens")
}
model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
@@ -241,7 +186,7 @@ model Features {
@@map("features")
}
model DeprecatedNextAuthAccount {
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
@@ -255,20 +200,23 @@ model DeprecatedNextAuthAccount {
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model DeprecatedNextAuthSession {
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model DeprecatedNextAuthVerificationToken {
model VerificationToken {
identifier String
token String @unique
expires DateTime

View File

@@ -0,0 +1,37 @@
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
import { hash } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.user.create({
data: {
...userA,
password: await hash(userA.password),
features: {
create: {
reason: 'created by api sign up',
activated: true,
feature: {
connect: {
feature_version: {
feature: 'free_plan_v1',
version: 1,
},
},
},
},
},
},
});
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async e => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -1,13 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from './core/auth';
import { Config } from './fundamentals/config';
@Controller('/')
export class AppController {
constructor(private readonly config: Config) {}
@Public()
@Get()
info() {
return {

View File

@@ -1,20 +1,20 @@
import { join } from 'node:path';
import { Logger, Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { get } from 'lodash-es';
import { AppController } from './app.controller';
import { AuthGuard, AuthModule } from './core/auth';
import { AuthModule } from './core/auth';
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
import { DocModule } from './core/doc';
import { FeatureModule } from './core/features';
import { QuotaModule } from './core/quota';
import { StorageModule } from './core/storage';
import { SyncModule } from './core/sync';
import { UserModule } from './core/user';
import { UsersModule } from './core/users';
import { WorkspaceModule } from './core/workspaces';
import { getOptionalModuleMetadata } from './fundamentals';
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
@@ -25,28 +25,24 @@ import {
} from './fundamentals/config';
import { EventModule } from './fundamentals/event';
import { GqlModule } from './fundamentals/graphql';
import { HelpersModule } from './fundamentals/helpers';
import { MailModule } from './fundamentals/mailer';
import { MetricsModule } from './fundamentals/metrics';
import { MutexModule } from './fundamentals/mutex';
import { PrismaModule } from './fundamentals/prisma';
import { StorageProviderModule } from './fundamentals/storage';
import { SessionModule } from './fundamentals/session';
import { RateLimiterModule } from './fundamentals/throttler';
import { WebSocketModule } from './fundamentals/websocket';
import { REGISTERED_PLUGINS } from './plugins';
import { pluginsMap } from './plugins';
export const FunctionalityModules = [
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
EventModule,
CacheModule,
MutexModule,
PrismaModule,
MetricsModule,
RateLimiterModule,
SessionModule,
MailModule,
StorageProviderModule,
HelpersModule,
];
export class AppModuleBuilder {
@@ -111,10 +107,6 @@ export class AppModuleBuilder {
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
imports: this.modules,
controllers: this.config.isSelfhosted ? [] : [AppController],
@@ -147,7 +139,7 @@ function buildAppModule() {
WebSocketModule,
GqlModule,
StorageModule,
UserModule,
UsersModule,
WorkspaceModule,
FeatureModule,
QuotaModule
@@ -163,7 +155,7 @@ function buildAppModule() {
// plugin modules
AFFiNE.plugins.enabled.forEach(name => {
const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins);
const plugin = pluginsMap.get(name as AvailablePlugins);
if (!plugin) {
throw new Error(`Unknown plugin ${name}`);
}

View File

@@ -4,8 +4,9 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { GlobalExceptionFilter } from './fundamentals';
import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket';
import { SocketIoAdapter } from './fundamentals';
import { SocketIoAdapterImpl } from './fundamentals/websocket';
import { ExceptionLogger } from './middleware/exception-logger';
import { serverTimingAndCache } from './middleware/timing';
export async function createApp() {
@@ -28,7 +29,7 @@ export async function createApp() {
})
);
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
app.useGlobalFilters(new ExceptionLogger());
app.use(cookieParser());
if (AFFiNE.flavor.sync) {

View File

@@ -7,10 +7,12 @@ AFFiNE.ENV_MAP = {
DATABASE_URL: 'db.url',
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret',
OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret',
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'],
OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret',
MAILER_HOST: 'mailer.host',
MAILER_PORT: ['mailer.port', 'int'],
MAILER_USER: 'mailer.auth.user',
@@ -32,8 +34,4 @@ AFFiNE.ENV_MAP = {
STRIPE_API_KEY: 'plugins.payment.stripe.keys.APIKey',
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
FEATURES_SYNC_CLIENT_VERSION_CHECK: [
'featureFlags.syncClientVersionCheck',
'boolean',
],
};

View File

@@ -20,19 +20,19 @@ const env = process.env;
AFFiNE.metrics.enabled = !AFFiNE.node.test;
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.plugins.use('cloudflare-r2', {
AFFiNE.storage.providers.r2 = {
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
credentials: {
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
},
});
AFFiNE.storage.storages.avatar.provider = 'cloudflare-r2';
};
AFFiNE.storage.storages.avatar.provider = 'r2';
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
`https://avatar.affineassets.com/${key}`;
AFFiNE.storage.storages.blob.provider = 'cloudflare-r2';
AFFiNE.storage.storages.blob.provider = 'r2';
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
@@ -40,7 +40,6 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment');
AFFiNE.plugins.use('oauth');
if (AFFiNE.deploy) {
AFFiNE.mailer = {

View File

@@ -87,55 +87,8 @@ AFFiNE.port = 3010;
AFFiNE.plugins.use('redis', {
/* override options */
});
//
//
// /* Payment Plugin */
AFFiNE.plugins.use('payment', {
stripe: { keys: {}, apiVersion: '2023-10-16' },
});
//
//
// /* Cloudflare R2 Plugin */
// /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */
// AFFiNE.plugins.use('cloudflare-r2', {
// accountId: '',
// credentials: {
// accessKeyId: '',
// secretAccessKey: '',
// },
// });
//
// /* AWS S3 Plugin */
// /* Enable if you choose to store workspace blobs or user avatars in AWS S3 Storage Service */
// AFFiNE.plugins.use('aws-s3', {
// credentials: {
// accessKeyId: '',
// secretAccessKey: '',
// })
// /* Update the provider of storages */
// AFFiNE.storage.storages.blob.provider = 'r2';
// AFFiNE.storage.storages.avatar.provider = 'r2';
//
// /* OAuth Plugin */
// AFFiNE.plugins.use('oauth', {
// providers: {
// github: {
// clientId: '',
// clientSecret: '',
// // See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
// args: {
// scope: 'user',
// },
// },
// google: {
// clientId: '',
// clientSecret: '',
// args: {
// // See https://developers.google.com/identity/protocols/oauth2
// scope: 'openid email profile',
// promot: 'select_account',
// access_type: 'offline',
// },
// },
// },
// });

View File

@@ -1,213 +0,0 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Body,
Controller,
Get,
Header,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
Config,
PaymentRequiredException,
URLHelper,
} from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
import { Public } from './guard';
import { AuthService, parseAuthUserSeqNum } from './service';
import { TokenService, TokenType } from './token';
class SignInCredential {
email!: string;
password?: string;
}
@Controller('/api/auth')
export class AuthController {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
) {}
@Public()
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
@Req() req: Request,
@Res() res: Response,
@Body() credential: SignInCredential,
@Query('redirect_uri') redirectUri = this.url.home
) {
validators.assertValidEmail(credential.email);
const canSignIn = await this.auth.canSignIn(credential.email);
if (!canSignIn) {
throw new PaymentRequiredException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
);
}
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);
} else {
// send email magic link
const user = await this.user.findUserByEmail(credential.email);
const result = await this.sendSignInEmail(
{ email: credential.email, signUp: !user },
redirectUri
);
if (result.rejected.length) {
throw new Error('Failed to send sign-in email.');
}
res.send({
email: credential.email,
});
}
}
async sendSignInEmail(
{ email, signUp }: { email: string; signUp: boolean },
redirectUri: string
) {
const token = await this.token.createToken(TokenType.SignIn, email);
const magicLink = this.url.link('/api/auth/magic-link', {
token,
email,
redirect_uri: redirectUri,
});
const result = await this.auth.sendSignInEmail(email, magicLink, signUp);
return result;
}
@Get('/sign-out')
async signOut(
@Req() req: Request,
@Res() res: Response,
@Query('redirect_uri') redirectUri?: string
) {
const session = await this.auth.signOut(
req.cookies[AuthService.sessionCookieName],
parseAuthUserSeqNum(req.headers[AuthService.authUserSeqHeaderName])
);
if (session) {
res.cookie(AuthService.sessionCookieName, session.id, {
expires: session.expiresAt ?? void 0, // expiredAt is `string | null`
...this.auth.cookieOptions,
});
} else {
res.clearCookie(AuthService.sessionCookieName);
}
if (redirectUri) {
return this.url.safeRedirect(res, redirectUri);
} else {
return res.send(null);
}
}
@Public()
@Get('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
@Res() res: Response,
@Query('token') token?: string,
@Query('email') email?: string,
@Query('redirect_uri') redirectUri = this.url.home
) {
if (!token || !email) {
throw new BadRequestException('Invalid Sign-in mail Token');
}
email = decodeURIComponent(email);
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
credential: email,
});
if (!valid) {
throw new BadRequestException('Invalid Sign-in mail Token');
}
const user = await this.user.fulfillUser(email, {
emailVerifiedAt: new Date(),
registered: true,
});
await this.auth.setCookie(req, res, user);
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) {
return {
user,
};
}
@Public()
@Get('/sessions')
async currentSessionUsers(@Req() req: Request) {
const token = req.cookies[AuthService.sessionCookieName];
if (!token) {
return {
users: [],
};
}
return {
users: await this.auth.getUserList(token),
};
}
@Public()
@Get('/challenge')
async challenge() {
// TODO: impl in following PR
return {
challenge: randomUUID(),
resource: randomUUID(),
};
}
}

View File

@@ -1,55 +0,0 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import { User } from '@prisma/client';
import { getRequestResponseFromContext } from '../../fundamentals';
function getUserFromContext(context: ExecutionContext) {
return getRequestResponseFromContext(context).req.user;
}
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token or session cookie is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user')
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*
* ```typescript
* // for public apis
* \@Public()
* \@Get('/session')
* session(@currentUser() user?: CurrentUser) {
* return user
* }
* ```
*/
// interface and variable don't conflict
// eslint-disable-next-line no-redeclare
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
export interface CurrentUser
extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name'> {
hasPassword: boolean | null;
emailVerified: boolean;
}

View File

@@ -1,90 +1,128 @@
import type {
CanActivate,
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import {
createParamDecorator,
Inject,
Injectable,
SetMetadata,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { Reflector } from '@nestjs/core';
import type { NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import { Config, getRequestResponseFromContext } from '../../fundamentals';
import { AuthService, parseAuthUserSeqNum } from './service';
import {
getRequestResponseFromContext,
PrismaService,
} from '../../fundamentals';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
function extractTokenFromHeader(authorization: string) {
if (!/^Bearer\s/i.test(authorization)) {
return;
}
return authorization.substring(7);
export function getUserFromContext(context: ExecutionContext) {
return getRequestResponseFromContext(context).req.user;
}
@Injectable()
export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
@Injectable()
class AuthGuard implements CanActivate {
constructor(
private readonly config: Config,
private readonly ref: ModuleRef,
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private readonly auth: AuthService,
private readonly prisma: PrismaService,
private readonly reflector: Reflector
) {}
onModuleInit() {
this.auth = this.ref.get(AuthService, { strict: false });
}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
// check cookie
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);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const user = await this.auth.getUser(sessionToken, userSeq);
if (user) {
req.user = user;
}
}
const { req, res } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
// api is public
const isPublic = this.reflector.get<boolean>(
'isPublic',
context.getHandler()
);
// api can be public, but if user is logged in, we can get user info
const isPublicable = this.reflector.get<boolean>(
'isPublicable',
context.getHandler()
);
if (isPublic) {
return true;
}
} else if (!token) {
if (!req.cookies) {
return isPublicable;
}
if (!req.user) {
throw new UnauthorizedException('You are not signed in.');
}
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
return true;
const { body = {}, cookies, status = 200 } = session;
if (!body && !isPublicable) {
return false;
}
// @ts-expect-error body is user here
req.user = body.user;
if (cookies && res) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
return Boolean(
status === 200 &&
typeof body !== 'string' &&
// ignore body if api is publicable
(Object.keys(body).length || isPublicable)
);
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
return !!req.user;
}
}
return false;
}
}
@@ -99,7 +137,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
* ```typescript
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: CurrentUser) {
* user(@CurrentUser() user: User) {
* return user;
* }
* ```
@@ -110,3 +148,5 @@ export const Auth = () => {
// api is public accessible
export const Public = () => SetMetadata('isPublic', true);
// api is public accessible, but if user is logged in, we can get user info
export const Publicable = () => SetMetadata('isPublicable', true);

View File

@@ -1,21 +1,18 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { FeatureModule } from '../features';
import { UserModule } from '../user';
import { AuthController } from './controller';
import { NextAuthController } from './next-auth.controller';
import { NextAuthOptionsProvider } from './next-auth-options';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService } from './token';
@Global()
@Module({
imports: [FeatureModule, UserModule],
providers: [AuthService, AuthResolver, TokenService],
exports: [AuthService],
controllers: [AuthController],
providers: [AuthService, AuthResolver, NextAuthOptionsProvider],
exports: [AuthService, NextAuthOptionsProvider],
controllers: [NextAuthController],
})
export class AuthModule {}
export * from './guard';
export { ClientTokenType } from './resolver';
export { TokenType } from './resolver';
export { AuthService };
export * from './current-user';

View File

@@ -0,0 +1,285 @@
import { PrismaAdapter } from '@auth/prisma-adapter';
import { FactoryProvider, Logger } from '@nestjs/common';
import { verify } from '@node-rs/argon2';
import { assign, omit } from 'lodash-es';
import { NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Email from 'next-auth/providers/email';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import {
Config,
MailService,
PrismaService,
SessionService,
} from '../../fundamentals';
import { FeatureType } from '../features';
import { Quota_FreePlanV1_1 } from '../quota';
import {
decode,
encode,
sendVerificationRequest,
SendVerificationRequestParams,
} from './utils';
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
const TrustedProviders = ['google'];
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
provide: NextAuthOptionsProvide,
useFactory(
config: Config,
prisma: PrismaService,
mailer: MailService,
session: SessionService
) {
const logger = new Logger('NextAuth');
const prismaAdapter = PrismaAdapter(prisma);
// createUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
prismaAdapter.createUser = async data => {
const userData = {
name: data.name,
email: data.email,
avatarUrl: '',
emailVerified: data.emailVerified,
features: {
create: {
reason: 'created by email sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
};
if (data.email && !data.name) {
userData.name = data.email.split('@')[0];
}
if (data.image) {
userData.avatarUrl = data.image;
}
return createUser(userData);
};
// linkAccount exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const linkAccount = prismaAdapter.linkAccount!.bind(prismaAdapter);
prismaAdapter.linkAccount = async account => {
// google account must be a verified email
if (TrustedProviders.includes(account.provider)) {
await prisma.user.update({
where: {
id: account.userId,
},
data: {
emailVerified: new Date(),
},
});
}
return linkAccount(account) as Promise<void>;
};
// getUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!;
prismaAdapter.getUser = async id => {
const result = await getUser(id);
if (result) {
// @ts-expect-error Third part library type mismatch
result.image = result.avatarUrl;
// @ts-expect-error Third part library type mismatch
result.hasPassword = Boolean(result.password);
}
return result;
};
prismaAdapter.createVerificationToken = async data => {
await session.set(
`${data.identifier}:${data.token}`,
Date.now() + session.sessionTtl
);
return data;
};
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
const expires = await session.get(`${identifier}:${token}`);
if (expires) {
return { identifier, token, expires: new Date(expires) };
} else {
return null;
}
};
const nextAuthOptions: NextAuthOptions = {
providers: [],
adapter: prismaAdapter,
debug: !config.node.prod,
logger: {
debug(code, metadata) {
logger.debug(`${code}: ${JSON.stringify(metadata)}`);
},
error(code, metadata) {
if (metadata instanceof Error) {
// @ts-expect-error assign code to error
metadata.code = code;
logger.error(metadata);
} else if (metadata.error instanceof Error) {
assign(metadata.error, omit(metadata, 'error'), { code });
logger.error(metadata.error);
}
},
warn(code) {
logger.warn(code);
},
},
};
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Credentials.default({
name: 'Password',
credentials: {
email: {
label: 'Email',
type: 'text',
placeholder: 'torvalds@osdl.org',
},
password: { label: 'Password', type: 'password' },
},
async authorize(
credentials:
| Record<'email' | 'password' | 'hashedPassword', string>
| undefined
) {
if (!credentials) {
return null;
}
const { password, hashedPassword } = credentials;
if (!password || !hashedPassword) {
return null;
}
if (!(await verify(hashedPassword, password))) {
return null;
}
return credentials;
},
})
);
if (config.mailer && mailer) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Email.default({
sendVerificationRequest: (params: SendVerificationRequestParams) =>
sendVerificationRequest(config, logger, mailer, session, params),
})
);
}
if (config.auth.oauthProviders.github) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Github.default({
clientId: config.auth.oauthProviders.github.clientId,
clientSecret: config.auth.oauthProviders.github.clientSecret,
allowDangerousEmailAccountLinking: true,
})
);
}
if (config.auth.oauthProviders.google?.enabled) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Google.default({
clientId: config.auth.oauthProviders.google.clientId,
clientSecret: config.auth.oauthProviders.google.clientSecret,
checks: 'nonce',
allowDangerousEmailAccountLinking: true,
authorization: {
params: { scope: 'openid email profile', prompt: 'select_account' },
},
})
);
}
if (nextAuthOptions.providers.length > 1) {
// not only credentials provider
nextAuthOptions.session = { strategy: 'database' };
}
nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) =>
encode(config, prisma, token, maxAge),
decode: async ({ token }) => decode(config, token),
};
nextAuthOptions.secret ??= config.auth.nextAuthSecret;
nextAuthOptions.callbacks = {
session: async ({ session, user, token }) => {
if (session.user) {
if (user) {
// @ts-expect-error Third part library type mismatch
session.user.id = user.id;
// @ts-expect-error Third part library type mismatch
session.user.image = user.image ?? user.avatarUrl;
// @ts-expect-error Third part library type mismatch
session.user.emailVerified = user.emailVerified;
// @ts-expect-error Third part library type mismatch
session.user.hasPassword = Boolean(user.password);
} else {
// technically the sub should be the same as id
// @ts-expect-error Third part library type mismatch
session.user.id = token.sub;
// @ts-expect-error Third part library type mismatch
session.user.emailVerified = token.emailVerified;
// @ts-expect-error Third part library type mismatch
session.user.hasPassword = token.hasPassword;
}
if (token && token.picture) {
session.user.image = token.picture;
}
}
return session;
},
signIn: async ({ profile, user }) => {
if (!config.featureFlags.earlyAccessPreview) {
return true;
}
const email = profile?.email ?? user.email;
if (email) {
// FIXME: cannot inject FeatureManagementService here
// it will cause prisma.account to be undefined
// then prismaAdapter.getUserByAccount will throw error
if (email.endsWith('@toeverything.info')) return true;
return prisma.userFeatures
.count({
where: {
user: {
email,
},
feature: {
feature: FeatureType.EarlyAccess,
},
activated: true,
},
})
.then(count => count > 0);
}
return false;
},
redirect({ url }) {
return url;
},
};
nextAuthOptions.pages = {
newUser: '/auth/onboarding',
};
return nextAuthOptions;
},
inject: [Config, PrismaService, MailService, SessionService],
};

View File

@@ -0,0 +1,409 @@
import { URLSearchParams } from 'node:url';
import {
All,
BadRequestException,
Controller,
Get,
Inject,
Logger,
Next,
NotFoundException,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { hash, verify } from '@node-rs/argon2';
import type { User } from '@prisma/client';
import type { NextFunction, Request, Response } from 'express';
import { pick } from 'lodash-es';
import { nanoid } from 'nanoid';
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import {
AuthThrottlerGuard,
Config,
metrics,
PrismaService,
SessionService,
Throttle,
} from '../../fundamentals';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
const BASE_URL = '/api/auth/';
const DEFAULT_SESSION_EXPIRE_DATE = 2592000 * 1000; // 30 days
@Controller(BASE_URL)
export class NextAuthController {
private readonly callbackSession;
private readonly logger = new Logger('NextAuthController');
constructor(
readonly config: Config,
readonly prisma: PrismaService,
private readonly authService: AuthService,
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private readonly session: SessionService
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.callbackSession = nextAuthOptions.callbacks!.session;
}
@UseGuards(AuthThrottlerGuard)
@Throttle({
default: {
limit: 60,
ttl: 60,
},
})
@Get('/challenge')
async getChallenge(@Res() res: Response) {
const challenge = nanoid();
const resource = nanoid();
await this.session.set(challenge, resource, 5 * 60 * 1000);
res.json({ challenge, resource });
}
@UseGuards(AuthThrottlerGuard)
@Throttle({
default: {
limit: 60,
ttl: 60,
},
})
@All('*')
async auth(
@Req() req: Request,
@Res() res: Response,
@Query() query: Record<string, any>,
@Next() next: NextFunction
) {
if (req.path === '/api/auth/signin' && req.method === 'GET') {
const query = req.query
? // @ts-expect-error req.query is satisfy with the Record<string, any>
`?${new URLSearchParams(req.query).toString()}`
: '';
res.redirect(`/signin${query}`);
return;
}
const [action, providerId] = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part
.split('/') as [AuthAction, string]; // as array of strings;
metrics.auth.counter('call_counter').add(1, { action, providerId });
const credentialsSignIn =
req.method === 'POST' && providerId === 'credentials';
let userId: string | undefined;
if (credentialsSignIn) {
const { email } = req.body;
if (email) {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
req.statusCode = 401;
req.statusMessage = 'User not found';
req.body = null;
throw new NotFoundException(`User not found`);
} else {
userId = user.id;
req.body = {
...req.body,
name: user.name,
email: user.email,
image: user.avatarUrl,
hashedPassword: user.password,
};
}
}
}
const options = this.nextAuthOptions;
if (req.method === 'POST' && action === 'session') {
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
metrics.auth
.counter('call_fails_counter')
.add(1, { reason: 'invalid_session_data' });
throw new BadRequestException(`Invalid new session data`);
}
const user = await this.updateSession(req, req.body.data);
// callbacks.session existed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.callbacks!.session = ({ session }) => {
return {
user: {
...pick(user, 'id', 'name', 'email'),
image: user.avatarUrl,
hasPassword: !!user.password,
},
expires: session.expires,
};
};
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.callbacks!.session = this.callbackSession;
}
if (
this.config.auth.captcha.enable &&
req.method === 'POST' &&
action === 'signin' &&
// TODO: add credentials support in frontend
['email'].includes(providerId)
) {
const isVerified = await this.verifyChallenge(req, res);
if (!isVerified) return;
}
const { status, headers, body, redirect, cookies } = await AuthHandler({
req: {
body: req.body,
query: query,
method: req.method,
action,
providerId,
error: query.error ?? providerId,
cookies: req.cookies,
},
options,
});
if (headers) {
for (const { key, value } of headers) {
res.setHeader(key, value);
}
}
if (cookies) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined;
const secureCookiePrefix = '__Secure-';
const sessionCookieName = `next-auth.session-token`;
// next-auth credentials login only support JWT strategy
// https://next-auth.js.org/configuration/providers/credentials
// let's store the session token in the database
if (
credentialsSignIn &&
(nextAuthTokenCookie = cookies?.find(
({ name }) =>
name === sessionCookieName ||
name === `${secureCookiePrefix}${sessionCookieName}`
))
) {
const cookieExpires = new Date();
cookieExpires.setTime(
cookieExpires.getTime() + DEFAULT_SESSION_EXPIRE_DATE
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await this.nextAuthOptions.adapter!.createSession!({
sessionToken: nextAuthTokenCookie.value,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
userId: userId!,
expires: cookieExpires,
});
}
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
this.logger.log(`Early access redirect headers: ${req.headers}`);
metrics.auth
.counter('call_fails_counter')
.add(1, { reason: 'no_early_access_permission' });
if (
!req.headers?.referer ||
checkUrlOrigin(req.headers.referer, 'https://accounts.google.com')
) {
res.redirect('https://community.affine.pro/c/insider-general/');
} else {
res.status(403);
res.json({
url: 'https://community.affine.pro/c/insider-general/',
error: `You don't have early access permission`,
});
}
return;
}
if (status) {
res.status(status);
}
if (redirect) {
if (providerId === 'credentials') {
res.send(JSON.stringify({ ok: true, url: redirect }));
} else if (
action === 'callback' ||
action === 'error' ||
(providerId !== 'credentials' &&
// login in the next-auth page, /api/auth/signin, auto redirect.
// otherwise, return the json value to allow frontend to handle the redirect.
req.headers?.referer?.includes?.('/api/auth/signin'))
) {
res.redirect(redirect);
} else {
res.json({ url: redirect });
}
} else if (typeof body === 'string') {
res.send(body);
} else if (body && typeof body === 'object') {
res.json(body);
} else {
next();
}
}
private async updateSession(
req: Request,
newSession: Partial<Omit<User, 'id'>> & { oldPassword?: string }
): Promise<User> {
const { name, email, password, oldPassword } = newSession;
if (!name && !email && !password) {
throw new BadRequestException(`Invalid new session data`);
}
if (password) {
const user = await this.verifyUserFromRequest(req);
const { password: userPassword } = user;
if (!oldPassword) {
if (userPassword) {
throw new BadRequestException(
`Old password is required to update password`
);
}
} else {
if (!userPassword) {
throw new BadRequestException(`No existed password`);
}
if (await verify(userPassword, oldPassword)) {
await this.prisma.user.update({
where: {
id: user.id,
},
data: {
...pick(newSession, 'email', 'name'),
password: await hash(password),
},
});
}
}
return user;
} else {
const user = await this.verifyUserFromRequest(req);
return this.prisma.user.update({
where: {
id: user.id,
},
data: pick(newSession, 'name', 'email'),
});
}
}
private async verifyChallenge(req: Request, res: Response): Promise<boolean> {
const challenge = req.query?.challenge;
if (typeof challenge === 'string' && challenge) {
const resource = await this.session.get(challenge);
if (!resource) {
this.rejectResponse(res, 'Invalid Challenge');
return false;
}
const isChallengeVerified =
await this.authService.verifyChallengeResponse(
req.query?.token,
resource
);
this.logger.debug(
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
);
if (!isChallengeVerified) {
this.rejectResponse(res, 'Invalid Challenge Response');
return false;
}
} else {
const isTokenVerified = await this.authService.verifyCaptchaToken(
req.query?.token,
req.headers['CF-Connecting-IP'] as string
);
if (!isTokenVerified) {
this.rejectResponse(res, 'Invalid Captcha Response');
return false;
}
}
return true;
}
private async verifyUserFromRequest(req: Request): Promise<User> {
const token = req.headers.authorization;
if (!token) {
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
const { body } = session;
// @ts-expect-error check if body.user exists
if (body && body.user && body.user.id) {
const user = await this.prisma.user.findUnique({
where: {
// @ts-expect-error body.user.id exists
id: body.user.id,
},
});
if (user) {
return user;
}
}
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.authService.verify(jwt);
const user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
if (user) {
return user;
}
}
}
throw new BadRequestException(`User not found`);
}
rejectResponse(res: Response, error: string, status = 400) {
res.status(status);
res.json({
url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({
error,
}).toString()}`,
error,
});
}
}
const checkUrlOrigin = (url: string, origin: string) => {
try {
return new URL(url).origin === origin;
} catch (e) {
return false;
}
};

View File

@@ -10,22 +10,24 @@ import {
Mutation,
ObjectType,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { Request, Response } from 'express';
import type { Request } from 'express';
import { nanoid } from 'nanoid';
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
import { Public } from './guard';
import {
CloudThrottlerGuard,
Config,
SessionService,
Throttle,
} from '../../fundamentals';
import { UserType } from '../users';
import { Auth, CurrentUser } from './guard';
import { AuthService } from './service';
import { TokenService, TokenType } from './token';
@ObjectType('tokenType')
export class ClientTokenType {
@ObjectType()
export class TokenType {
@Field()
token!: string;
@@ -48,57 +50,46 @@ export class AuthResolver {
constructor(
private readonly config: Config,
private readonly auth: AuthService,
private readonly token: TokenService
private readonly session: SessionService
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Public()
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
nullable: true,
})
currentUser(@CurrentUser() user?: CurrentUser): UserType | undefined {
return user;
}
@Throttle({
default: {
limit: 20,
ttl: 60,
},
})
@ResolveField(() => ClientTokenType, {
name: 'token',
deprecationReason: 'use [/api/auth/authorize]',
})
async clientToken(
@CurrentUser() currentUser: CurrentUser,
@ResolveField(() => TokenType)
async token(
@Context() ctx: { req: Request },
@CurrentUser() currentUser: UserType,
@Parent() user: UserType
): Promise<ClientTokenType> {
) {
if (user.id !== currentUser.id) {
throw new ForbiddenException('Invalid user');
throw new BadRequestException('Invalid user');
}
const session = await this.auth.createUserSession(
user,
undefined,
this.config.auth.accessToken.ttl
);
let sessionToken: string | undefined;
// only return session if the request is from the same origin & path == /open-app
if (
ctx.req.headers.referer &&
ctx.req.headers.host &&
new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') &&
ctx.req.headers.host === new URL(this.config.origin).host
) {
const cookiePrefix = this.config.node.prod ? '__Secure-' : '';
const sessionCookieName = `${cookiePrefix}next-auth.session-token`;
sessionToken = ctx.req.cookies?.[sessionCookieName];
}
return {
sessionToken: session.sessionId,
token: session.sessionId,
refresh: '',
sessionToken,
token: this.auth.sign(user),
refresh: this.auth.refresh(user),
};
}
@Public()
@Throttle({
default: {
limit: 10,
@@ -107,19 +98,16 @@ export class AuthResolver {
})
@Mutation(() => UserType)
async signUp(
@Context() ctx: { req: Request; res: Response },
@Context() ctx: { req: Request },
@Args('name') name: string,
@Args('email') email: string,
@Args('password') password: string
) {
validators.assertValidCredential({ email, password });
const user = await this.auth.signUp(name, email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
return user;
}
@Public()
@Throttle({
default: {
limit: 10,
@@ -128,13 +116,11 @@ export class AuthResolver {
})
@Mutation(() => UserType)
async signIn(
@Context() ctx: { req: Request; res: Response },
@Context() ctx: { req: Request },
@Args('email') email: string,
@Args('password') password: string
) {
validators.assertValidCredential({ email, password });
const user = await this.auth.signIn(email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
return user;
}
@@ -146,26 +132,28 @@ export class AuthResolver {
},
})
@Mutation(() => UserType)
@Auth()
async changePassword(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('token') token: string,
@Args('newPassword') newPassword: string
) {
validators.assertValidPassword(newPassword);
// NOTE: Set & Change password are using the same token type.
const valid = await this.token.verifyToken(
TokenType.ChangePassword,
token,
{
credential: user.id,
}
);
if (!valid) {
const id = await this.session.get(token);
if (!user.emailVerified) {
throw new ForbiddenException('Please verify the email first');
}
if (
!id ||
(id !== user.id &&
// change password after sign in with email link
// we only create user account after user sign in with email link
id !== user.email)
) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changePassword(user.email, newPassword);
await this.session.delete(token);
return user;
}
@@ -177,24 +165,25 @@ export class AuthResolver {
},
})
@Mutation(() => UserType)
@Auth()
async changeEmail(
@CurrentUser() user: CurrentUser,
@Args('token') token: string,
@Args('email') email: string
@CurrentUser() user: UserType,
@Args('token') token: string
) {
validators.assertValidEmail(email);
// @see [sendChangeEmail]
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
credential: user.id,
});
if (!valid) {
const key = await this.session.get(token);
if (!key) {
throw new ForbiddenException('Invalid token');
}
email = decodeURIComponent(email);
// email has set token in `sendVerifyChangeEmail`
const [id, email] = key.split(',');
if (!id || id !== user.id || !email) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changeEmail(id, email);
await this.session.delete(token);
await this.auth.changeEmail(user.id, email);
await this.auth.sendNotificationChangeEmail(email);
return user;
@@ -207,29 +196,19 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendChangePasswordEmail(
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
// @deprecated
@Args('email', { nullable: true }) _email?: string
@CurrentUser() user: UserType,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
}
const token = await this.token.createToken(
TokenType.ChangePassword,
user.id
);
const token = nanoid();
await this.session.set(token, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendChangePasswordEmail(
user.email,
url.toString()
);
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
return !res.rejected.length;
}
@@ -240,27 +219,19 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendSetPasswordEmail(
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
@Args('email', { nullable: true }) _email?: string
@CurrentUser() user: UserType,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
}
const token = await this.token.createToken(
TokenType.ChangePassword,
user.id
);
const token = nanoid();
await this.session.set(token, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendSetPasswordEmail(
user.email,
url.toString()
);
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
return !res.rejected.length;
}
@@ -278,22 +249,19 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendChangeEmail(
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string,
// @deprecated
@Args('email', { nullable: true }) _email?: string
@CurrentUser() user: UserType,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
if (!user.emailVerified) {
throw new ForbiddenException('Please verify your email first.');
}
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
const token = nanoid();
await this.session.set(token, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendChangeEmail(user.email, url.toString());
const res = await this.auth.sendChangeEmail(email, url.toString());
return !res.rejected.length;
}
@@ -304,92 +272,34 @@ export class AuthResolver {
},
})
@Mutation(() => Boolean)
@Auth()
async sendVerifyChangeEmail(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('token') token: string,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
credential: user.id,
});
if (!valid) {
const id = await this.session.get(token);
if (!id || id !== user.id) {
throw new ForbiddenException('Invalid token');
}
const hasRegistered = await this.auth.getUserByEmail(email);
if (hasRegistered) {
if (hasRegistered.id !== user.id) {
throw new BadRequestException(`The email provided has been taken.`);
} else {
throw new BadRequestException(
`The email provided is the same as the current email.`
);
}
throw new BadRequestException(`Invalid user email`);
}
const verifyEmailToken = await this.token.createToken(
TokenType.VerifyEmail,
user.id
);
const withEmailToken = nanoid();
await this.session.set(withEmailToken, `${user.id},${email}`);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', verifyEmailToken);
url.searchParams.set('email', email);
url.searchParams.set('token', withEmailToken);
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
await this.session.delete(token);
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendVerifyEmail(
@CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string
) {
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async verifyEmail(
@CurrentUser() user: CurrentUser,
@Args('token') token: string
) {
if (!token) {
throw new BadRequestException('Invalid token');
}
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
credential: user.id,
});
if (!valid) {
throw new ForbiddenException('Invalid token');
}
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
return emailVerifiedAt !== null;
}
}

View File

@@ -1,333 +1,282 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Injectable,
NotAcceptableException,
NotFoundException,
OnApplicationBootstrap,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaClient, type User } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import { hash, verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import type { User } from '@prisma/client';
import { nanoid } from 'nanoid';
import {
Config,
CryptoHelper,
MailService,
SessionCache,
PrismaService,
verifyChallengeResponse,
} from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
import { UserService } from '../user/service';
import type { CurrentUser } from './current-user';
import { Quota_FreePlanV1_1 } from '../quota';
export function parseAuthUserSeqNum(value: any) {
switch (typeof value) {
case 'number': {
return value;
}
case 'string': {
value = Number.parseInt(value);
return Number.isNaN(value) ? 0 : value;
}
export type UserClaim = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
> & {
hasPassword?: boolean;
};
default: {
return 0;
}
}
}
export function sessionUser(
user: Pick<
User,
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
> & { password?: string | null }
): CurrentUser {
return assign(
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'),
{
hasPassword: user.password !== null,
emailVerified: user.emailVerifiedAt !== null,
}
);
}
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
@Injectable()
export class AuthService implements OnApplicationBootstrap {
readonly cookieOptions: CookieOptions = {
sameSite: 'lax',
httpOnly: true,
path: '/',
domain: this.config.host,
secure: this.config.https,
};
static readonly sessionCookieName = 'sid';
static readonly authUserSeqHeaderName = 'x-auth-user';
export class AuthService {
constructor(
private readonly config: Config,
private readonly db: PrismaClient,
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly user: UserService,
private readonly crypto: CryptoHelper,
private readonly cache: SessionCache
private readonly prisma: PrismaService,
private readonly mailer: MailService
) {}
async onApplicationBootstrap() {
if (this.config.node.dev) {
await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => {
// ignore
});
}
}
canSignIn(email: string) {
return this.feature.canEarlyAccess(email);
}
async signUp(
name: string,
email: string,
password: string
): Promise<CurrentUser> {
const user = await this.getUserByEmail(email);
if (user) {
throw new BadRequestException('Email was taken');
}
const hashedPassword = await this.crypto.encryptPassword(password);
return this.user
.createUser({
name,
email,
password: hashedPassword,
})
.then(sessionUser);
}
async signIn(email: string, password: string) {
const user = await this.user.findUserWithHashedPasswordByEmail(email);
if (!user) {
throw new NotFoundException('User Not Found');
}
if (!user.password) {
throw new NotAcceptableException(
'User Password is not set. Should login throw email link.'
);
}
const passwordMatches = await this.crypto.verifyPassword(
password,
user.password
);
if (!passwordMatches) {
throw new NotAcceptableException('Incorrect Password');
}
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);
// no such session
if (!session) {
return null;
}
const userSession = session.userSessions.at(seq);
// no such user session
if (!userSession) {
return null;
}
// user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return null;
}
const user = await this.db.user.findUnique({
where: { id: userSession.userId },
});
if (!user) {
return null;
}
return sessionUser(user);
}
async getUserList(token: string) {
const session = await this.getSession(token);
if (!session || !session.userSessions.length) {
return [];
}
const users = await this.db.user.findMany({
where: {
id: {
in: session.userSessions.map(({ userId }) => userId),
sign(user: UserClaim) {
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + this.config.auth.accessTokenExpiresIn,
iss: this.config.serverId,
sub: user.id,
aud: 'https://affine.pro',
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
refresh(user: UserClaim) {
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
exp: now + this.config.auth.refreshTokenExpiresIn,
iat: now,
iss: this.config.serverId,
sub: user.id,
aud: 'https://affine.pro',
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
async verify(token: string) {
try {
const data = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
aud: ['https://affine.pro'],
})
).data as UserClaim;
return {
...data,
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
createdAt: new Date(data.createdAt),
};
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
}
async verifyCaptchaToken(token: any, ip: string) {
if (typeof token !== 'string' || !token) return false;
const formData = new FormData();
formData.append('secret', this.config.auth.captcha.turnstile.secret);
formData.append('response', token);
formData.append('remoteip', ip);
// prevent replay attack
formData.append('idempotency_key', nanoid());
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});
const outcome = await result.json();
return (
!!outcome.success &&
// skip hostname check in dev mode
(this.config.node.dev || outcome.hostname === this.config.host)
);
}
async verifyChallengeResponse(response: any, resource: string) {
return verifyChallengeResponse(
response,
this.config.auth.captcha.challenge.bits,
resource
);
}
async signIn(email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
// TODO(@forehalo): need to separate expired session, same for [getUser]
// Session
// | { user: LimitedUser { email, avatarUrl }, expired: true }
// | { user: User, expired: false }
return users.map(sessionUser);
}
async signOut(token: string, seq = 0) {
const session = await this.getSession(token);
if (session) {
// overflow the logged in user
if (session.userSessions.length <= seq) {
return session;
}
await this.db.userSession.deleteMany({
where: { id: session.userSessions[seq].id },
});
// no more user session active, delete the whole session
if (session.userSessions.length === 1) {
await this.db.session.delete({ where: { id: session.id } });
return null;
}
return session;
}
return null;
}
async getSession(token: string) {
return this.db.$transaction(async tx => {
const session = await tx.session.findUnique({
where: {
id: token,
},
include: {
userSessions: {
orderBy: {
createdAt: 'asc',
},
},
},
});
if (!session) {
return null;
}
if (session.expiresAt && session.expiresAt <= new Date()) {
await tx.session.delete({
where: {
id: session.id,
},
});
return null;
}
return session;
});
}
async createUserSession(
user: { id: string },
existingSession?: string,
ttl = this.config.auth.session.ttl
) {
const session = existingSession
? await this.getSession(existingSession)
: null;
const expiresAt = new Date(Date.now() + ttl * 1000);
if (session) {
return this.db.userSession.upsert({
where: {
sessionId_userId: {
sessionId: session.id,
userId: user.id,
},
},
update: {
expiresAt,
},
create: {
sessionId: session.id,
userId: user.id,
expiresAt,
},
});
} else {
return this.db.userSession.create({
data: {
expiresAt,
session: {
create: {},
},
user: {
connect: {
id: user.id,
},
},
},
});
}
}
async setCookie(req: Request, res: Response, user: { id: string }) {
const session = await this.createUserSession(
user,
req.cookies[AuthService.sessionCookieName]
);
res.cookie(AuthService.sessionCookieName, session.sessionId, {
expires: session.expiresAt ?? void 0,
...this.cookieOptions,
});
}
async getUserByEmail(email: string) {
return this.user.findUserByEmail(email);
}
async changePassword(email: string, newPassword: string): Promise<User> {
const user = await this.getUserByEmail(email);
if (!user) {
throw new BadRequestException('Invalid email');
}
const hashedPassword = await this.crypto.encryptPassword(newPassword);
if (!user.password) {
throw new BadRequestException('User has no password');
}
let equal = false;
try {
equal = await verify(user.password, password);
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e, 'Verify password failed');
}
if (!equal) {
throw new UnauthorizedException('Invalid password');
}
return this.db.user.update({
return user;
}
async signUp(name: string, email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (user) {
throw new BadRequestException('Email already exists');
}
const hashedPassword = await hash(password);
return this.prisma.user.create({
data: {
name,
email,
password: hashedPassword,
// TODO(@forehalo): handle in event system
features: {
create: {
reason: 'created by api sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
},
});
}
async createAnonymousUser(email: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (user) {
throw new BadRequestException('Email already exists');
}
return this.prisma.user.create({
data: {
name: 'Unnamed',
email,
features: {
create: {
reason: 'created by invite sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
},
});
}
async getUserByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: {
email,
},
});
}
async isUserHasPassword(email: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
return Boolean(user.password);
}
async changePassword(email: string, newPassword: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: {
email,
emailVerified: {
not: null,
},
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
const hashedPassword = await hash(newPassword);
return this.prisma.user.update({
where: {
id: user.id,
},
@@ -338,7 +287,7 @@ export class AuthService implements OnApplicationBootstrap {
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.db.user.findUnique({
const user = await this.prisma.user.findUnique({
where: {
id,
},
@@ -348,27 +297,12 @@ export class AuthService implements OnApplicationBootstrap {
throw new BadRequestException('Invalid email');
}
return this.db.user.update({
return this.prisma.user.update({
where: {
id,
},
data: {
email: newEmail,
emailVerifiedAt: new Date(),
},
});
}
async setEmailVerified(id: string) {
return await this.db.user.update({
where: {
id,
},
data: {
emailVerifiedAt: new Date(),
},
select: {
emailVerifiedAt: true,
},
});
}
@@ -385,20 +319,7 @@ export class AuthService implements OnApplicationBootstrap {
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
}
async sendVerifyEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyEmail(email, callbackUrl);
}
async sendNotificationChangeEmail(email: string) {
return this.mailer.sendNotificationChangeEmail(email);
}
async sendSignInEmail(email: string, link: string, signUp: boolean) {
return signUp
? await this.mailer.sendSignUpMail(link.toString(), {
to: email,
})
: await this.mailer.sendSignInMail(link.toString(), {
to: email,
});
}
}

View File

@@ -1,84 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { CryptoHelper } from '../../fundamentals/helpers';
export enum TokenType {
SignIn,
VerifyEmail,
ChangeEmail,
ChangePassword,
Challenge,
}
@Injectable()
export class TokenService {
constructor(
private readonly db: PrismaClient,
private readonly crypto: CryptoHelper
) {}
async createToken(
type: TokenType,
credential?: string,
ttlInSec: number = 30 * 60
) {
const plaintextToken = randomUUID();
const { token } = await this.db.verificationToken.create({
data: {
type,
token: plaintextToken,
credential,
expiresAt: new Date(Date.now() + ttlInSec * 1000),
},
});
return this.crypto.encrypt(token);
}
async verifyToken(
type: TokenType,
token: string,
{
credential,
keep,
}: {
credential?: string;
keep?: boolean;
} = {}
) {
token = this.crypto.decrypt(token);
const record = await this.db.verificationToken.findUnique({
where: {
type_token: {
token,
type,
},
},
});
if (!record) {
return null;
}
const expired = record.expiresAt <= new Date();
const valid =
!expired && (!record.credential || record.credential === credential);
if ((expired || valid) && !keep) {
await this.db.verificationToken.delete({
where: {
type_token: {
token,
type,
},
},
});
}
return valid ? record : null;
}
}

View File

@@ -0,0 +1,3 @@
export { jwtDecode as decode, jwtEncode as encode } from './jwt';
export { sendVerificationRequest } from './send-mail';
export type { SendVerificationRequestParams } from 'next-auth/providers/email';

View File

@@ -0,0 +1,75 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { JWT } from 'next-auth/jwt';
import { Config, PrismaService } from '../../../fundamentals';
import { getUtcTimestamp, UserClaim } from '../service';
export const jwtEncode = async (
config: Config,
prisma: PrismaService,
token: JWT | undefined,
maxAge: number | undefined
) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
picture: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
hasPassword: Boolean(user.password),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: config.serverId,
sub: user.id,
aud: 'https://affine.pro',
jti: randomUUID({
disableEntropyCache: true,
}),
},
config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
};
export const jwtDecode = async (config: Config, token: string | undefined) => {
if (!token) {
return null;
}
const { name, email, emailVerified, id, picture, hasPassword } = (
await jwtVerify(token, config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [config.serverId],
leeway: config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as Omit<UserClaim, 'avatarUrl'> & {
picture: string | undefined;
};
return {
name,
email,
emailVerified,
picture,
sub: id,
id,
hasPassword,
};
};

View File

@@ -0,0 +1,38 @@
import { Logger } from '@nestjs/common';
import { nanoid } from 'nanoid';
import type { SendVerificationRequestParams } from 'next-auth/providers/email';
import { Config, MailService, SessionService } from '../../../fundamentals';
export async function sendVerificationRequest(
config: Config,
logger: Logger,
mailer: MailService,
session: SessionService,
params: SendVerificationRequestParams
) {
const { identifier, url } = params;
const urlWithToken = new URL(url);
const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || '';
if (!callbackUrl) {
throw new Error('callbackUrl is not set');
} else {
const newCallbackUrl = new URL(callbackUrl, config.origin);
const token = nanoid();
await session.set(token, identifier);
newCallbackUrl.searchParams.set('token', token);
urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString());
}
const result = await mailer.sendSignInEmail(urlWithToken.toString(), {
to: identifier,
});
logger.log(`send verification email success: ${result.accepted.join(', ')}`);
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
}
}

View File

@@ -2,11 +2,9 @@ import { Module } from '@nestjs/common';
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../fundamentals';
import { Public } from './auth';
export enum ServerFeature {
Payment = 'payment',
OAuth = 'oauth',
}
registerEnumType(ServerFeature, {
@@ -17,9 +15,9 @@ registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
const ENABLED_FEATURES: ServerFeature[] = [];
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
ENABLED_FEATURES.push(feature);
}
@ObjectType()
@@ -48,9 +46,7 @@ export class ServerConfigType {
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
}
export class ServerConfigResolver {
@Public()
@Query(() => ServerConfigType, {
description: 'server config',
})
@@ -64,7 +60,7 @@ export class ServerConfigResolver {
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
// this field should be removed after frontend feature flags implemented
flavor: AFFiNE.type,
features: Array.from(ENABLED_FEATURES),
features: ENABLED_FEATURES,
};
}
}

View File

@@ -2,13 +2,13 @@ import { isDeepStrictEqual } from 'node:util';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import {
Config,
type EventPayload,
metrics,
OnEvent,
PrismaService,
} from '../../fundamentals';
import { QuotaService } from '../quota';
import { Permission } from '../workspaces/types';
@@ -19,7 +19,7 @@ export class DocHistoryManager {
private readonly logger = new Logger(DocHistoryManager.name);
constructor(
private readonly config: Config,
private readonly db: PrismaClient,
private readonly db: PrismaService,
private readonly quota: QuotaService
) {}

View File

@@ -5,7 +5,7 @@ import {
OnModuleInit,
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient, Snapshot, Update } from '@prisma/client';
import { Snapshot, Update } from '@prisma/client';
import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs';
import {
@@ -25,6 +25,7 @@ import {
mergeUpdatesInApplyWay as jwstMergeUpdates,
metrics,
OnEvent,
PrismaService,
} from '../../fundamentals';
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
@@ -71,7 +72,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
private busy = false;
constructor(
private readonly db: PrismaClient,
private readonly db: PrismaService,
private readonly config: Config,
private readonly cache: Cache,
private readonly event: EventEmitter
@@ -229,12 +230,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
update: Buffer,
retryTimes = 10
) {
const timestamp = await new Promise<number>((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
defer(async () => {
const seq = await this.getUpdateSeq(workspaceId, guid);
const { createdAt } = await this.db.update.create({
await this.db.update.create({
select: {
createdAt: true,
seq: true,
},
data: {
workspaceId,
@@ -243,27 +244,23 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
blob: update,
},
});
return createdAt.getTime();
})
.pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({
next: timestamp => {
next: () => {
this.logger.debug(
`pushed 1 update for ${guid} in workspace ${workspaceId}`
);
resolve(timestamp);
resolve();
},
error: e => {
this.logger.error('Failed to push updates', e);
reject(new Error('Failed to push update'));
},
});
}).then(() => {
return this.updateCachedUpdatesCount(workspaceId, guid, 1);
});
await this.updateCachedUpdatesCount(workspaceId, guid, 1);
return timestamp;
}
async batchPush(
@@ -272,34 +269,24 @@ 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) => {
defer(async () => {
const seq = await this.getUpdateSeq(workspaceId, guid, updates.length);
let turn = 0;
const batchCount = 10;
for (const batch of chunk(updates, batchCount)) {
await this.db.update.createMany({
data: batch.map((update, i) => {
const subSeq = turn * batchCount + i + 1;
data: batch.map((update, i) => ({
workspaceId,
id: guid,
// `seq` is the last seq num of the batch
// example for 11 batched updates, start from seq num 20
// seq for first update in the batch should be:
// 31 - 11 + subSeq(0 * 10 + 0 + 1) = 21
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
const seq = lastSeq - updates.length + subSeq;
const createdAt = now + subSeq;
timestamp = Math.max(timestamp, createdAt);
return {
workspaceId,
id: guid,
blob: update,
seq,
createdAt: new Date(createdAt), // make sure the updates can be ordered by create time
};
}),
// 31 - 11 + 0 * 10 + 0 + 1 = 21
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
seq: seq - updates.length + turn * batchCount + i + 1,
blob: update,
})),
});
turn++;
}
@@ -317,56 +304,9 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
reject(new Error('Failed to push update'));
},
});
}).then(() => {
return this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
});
await this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
return timestamp;
}
/**
* Get latest timestamp of all docs in the workspace.
*/
@CallTimer('doc', 'get_stats')
async getStats(workspaceId: string, after: number | undefined = 0) {
const snapshots = await this.db.snapshot.findMany({
where: {
workspaceId,
updatedAt: {
gt: new Date(after),
},
},
select: {
id: true,
updatedAt: true,
},
});
const updates = await this.db.update.groupBy({
where: {
workspaceId,
createdAt: {
gt: new Date(after),
},
},
by: ['id'],
_max: {
createdAt: true,
},
});
const result: Record<string, number> = {};
snapshots.forEach(s => {
result[s.id] = s.updatedAt.getTime();
});
updates.forEach(u => {
if (u._max.createdAt) {
result[u.id] = u._max.createdAt.getTime();
}
});
return result;
}
/**

View File

@@ -1,4 +1,5 @@
import { PrismaTransaction } from '../../fundamentals';
import { PrismaClient } from '@prisma/client';
import { Feature, FeatureSchema, FeatureType } from './types';
class FeatureConfig {
@@ -66,7 +67,7 @@ export type FeatureConfigType<F extends FeatureType> = InstanceType<
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
export async function getFeature(prisma: PrismaTransaction, featureId: number) {
export async function getFeature(prisma: PrismaClient, featureId: number) {
const cachedQuota = FeatureCache.get(featureId);
if (cachedQuota) {

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Config } from '../../fundamentals';
import { Config, PrismaService } from '../../fundamentals';
import { FeatureService } from './service';
import { FeatureType } from './types';
@@ -13,7 +12,7 @@ export class FeatureManagementService {
constructor(
private readonly feature: FeatureService,
private readonly prisma: PrismaClient,
private readonly prisma: PrismaService,
private readonly config: Config
) {}
@@ -51,10 +50,7 @@ export class FeatureManagementService {
async isEarlyAccessUser(email: string) {
const user = await this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
email,
},
});
if (user) {
@@ -115,10 +111,4 @@ export class FeatureManagementService {
async listFeatureWorkspaces(feature: FeatureType) {
return this.feature.listFeatureWorkspaces(feature);
}
async getUserFeatures(userId: string): Promise<FeatureType[]> {
return (await this.feature.getUserFeatures(userId)).map(
f => f.feature.name
);
}
}

View File

@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaService } from '../../fundamentals';
import { UserType } from '../users/types';
import { WorkspaceType } from '../workspaces/types';
import { FeatureConfigType, getFeature } from './feature';
import { FeatureKind, FeatureType } from './types';
@Injectable()
export class FeatureService {
constructor(private readonly prisma: PrismaClient) {}
constructor(private readonly prisma: PrismaService) {}
async getFeaturesVersion() {
const features = await this.prisma.features.findMany({
@@ -157,7 +158,7 @@ export class FeatureService {
return configs.filter(feature => !!feature.feature);
}
async listFeatureUsers(feature: FeatureType) {
async listFeatureUsers(feature: FeatureType): Promise<UserType[]> {
return this.prisma.userFeatures
.findMany({
where: {
@@ -174,7 +175,7 @@ export class FeatureService {
name: true,
avatarUrl: true,
email: true,
emailVerifiedAt: true,
emailVerified: true,
createdAt: true,
},
},

View File

@@ -1,4 +1,4 @@
import { PrismaTransaction } from '../../fundamentals';
import { PrismaService } from '../../fundamentals';
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
const QuotaCache = new Map<number, QuotaConfig>();
@@ -6,14 +6,14 @@ const QuotaCache = new Map<number, QuotaConfig>();
export class QuotaConfig {
readonly config: Quota;
static async get(tx: PrismaTransaction, featureId: number) {
static async get(prisma: PrismaService, featureId: number) {
const cachedQuota = QuotaCache.get(featureId);
if (cachedQuota) {
return cachedQuota;
}
const quota = await tx.features.findFirst({
const quota = await prisma.features.findFirst({
where: {
id: featureId,
},

View File

@@ -1,4 +1,4 @@
import { FeatureKind } from '../features/types';
import { FeatureKind } from '../features';
import { OneDay, OneGB, OneMB } from './constant';
import { Quota, QuotaType } from './types';

View File

@@ -1,18 +1,15 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import {
type EventPayload,
OnEvent,
PrismaTransaction,
} from '../../fundamentals';
import { type EventPayload, OnEvent, PrismaService } from '../../fundamentals';
import { FeatureKind } from '../features';
import { QuotaConfig } from './quota';
import { QuotaType } from './types';
type Transaction = Parameters<Parameters<PrismaService['$transaction']>[0]>[0];
@Injectable()
export class QuotaService {
constructor(private readonly prisma: PrismaClient) {}
constructor(private readonly prisma: PrismaService) {}
// get activated user quota
async getUserQuota(userId: string) {
@@ -142,8 +139,8 @@ export class QuotaService {
});
}
async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
const executor = tx ?? this.prisma;
async hasQuota(userId: string, quota: QuotaType, transaction?: Transaction) {
const executor = transaction ?? this.prisma;
return executor.userFeatures
.count({

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { FeatureService, FeatureType } from '../features';
import { WorkspaceBlobStorage } from '../storage';
@@ -11,8 +11,6 @@ type QuotaBusinessType = QuotaQueryType & { businessBlobLimit: number };
@Injectable()
export class QuotaManagementService {
protected logger = new Logger(QuotaManagementService.name);
constructor(
private readonly feature: FeatureService,
private readonly quota: QuotaService,
@@ -40,22 +38,11 @@ export class QuotaManagementService {
async getUserUsage(userId: string) {
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
const sizes = await Promise.allSettled(
const sizes = await Promise.all(
workspaces.map(workspace => this.storage.totalSize(workspace))
);
return sizes.reduce((total, size) => {
if (size.status === 'fulfilled') {
if (Number.isSafeInteger(size.value)) {
return total + size.value;
} else {
this.logger.error(`Workspace size is invalid: ${size.value}`);
}
} else {
this.logger.error(`Failed to get workspace size: ${size.reason}`);
}
return total;
}, 0);
return sizes.reduce((total, size) => total + size, 0);
}
// get workspace's owner quota and total size of used

View File

@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { z } from 'zod';
import { commonFeatureSchema, FeatureKind } from '../features/types';
import { commonFeatureSchema, FeatureKind } from '../features';
import { ByteUnit, OneDay, OneKB } from './constant';
/// ======== quota define ========

View File

@@ -6,18 +6,15 @@ import type {
PutObjectMetadata,
StorageProvider,
} from '../../../fundamentals';
import { Config, OnEvent, StorageProviderFactory } from '../../../fundamentals';
import { Config, createStorageProvider, OnEvent } from '../../../fundamentals';
@Injectable()
export class AvatarStorage {
public readonly provider: StorageProvider;
private readonly storageConfig: Config['storage']['storages']['avatar'];
constructor(
private readonly config: Config,
private readonly storageFactory: StorageProviderFactory
) {
this.provider = this.storageFactory.create('avatar');
constructor(private readonly config: Config) {
this.provider = createStorageProvider(this.config.storage, 'avatar');
this.storageConfig = this.config.storage.storages.avatar;
}

View File

@@ -6,9 +6,10 @@ import type {
StorageProvider,
} from '../../../fundamentals';
import {
Config,
createStorageProvider,
EventEmitter,
OnEvent,
StorageProviderFactory,
} from '../../../fundamentals';
@Injectable()
@@ -17,9 +18,9 @@ export class WorkspaceBlobStorage {
constructor(
private readonly event: EventEmitter,
private readonly storageFactory: StorageProviderFactory
private readonly config: Config
) {
this.provider = this.storageFactory.create('blob');
this.provider = createStorageProvider(this.config.storage, 'blob');
}
async put(workspaceId: string, key: string, blob: BlobInputType) {

View File

@@ -1,4 +1,4 @@
export enum EventErrorCode {
enum EventErrorCode {
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',

View File

@@ -14,6 +14,7 @@ import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { CallTimer, metrics } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { DocManager } from '../../doc';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../../workspaces/permission';
import { Permission } from '../../workspaces/types';
@@ -21,7 +22,6 @@ import {
AccessDeniedError,
DocNotFoundError,
EventError,
EventErrorCode,
InternalError,
NotInWorkspaceError,
} from './error';
@@ -38,21 +38,26 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
return desc;
}
desc.value = async function (...args: any[]) {
desc.value = function (...args: any[]) {
let result: any;
try {
return await originalMethod.apply(this, args);
result = originalMethod.apply(this, args);
} catch (e) {
if (e instanceof EventError) {
return {
error: e,
};
} else {
metrics.socketio.counter('unhandled_errors').add(1);
return {
error: new InternalError(e as Error),
};
}
if (result instanceof Promise) {
return result.catch(e => {
metrics.socketio.counter('unhandled_errors').add(1);
new Logger('EventsGateway').error(e, (e as Error).stack);
return {
error: new InternalError(e as Error),
error: new InternalError(e),
};
}
});
} else {
return result;
}
};
@@ -79,16 +84,8 @@ type EventResponse<Data = any> =
data: Data;
});
function Sync(workspaceId: string): `${string}:sync` {
return `${workspaceId}:sync`;
}
function Awareness(workspaceId: string): `${string}:awareness` {
return `${workspaceId}:awareness`;
}
@WebSocketGateway({
cors: !AFFiNE.node.prod,
cors: process.env.NODE_ENV !== 'production',
transports: ['websocket'],
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
maxHttpBufferSize: 1e8, // 100 MB
@@ -115,107 +112,89 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
}
assertVersion(client: Socket, version?: string) {
if (
// @todo(@darkskygit): remove this flag after 0.12 goes stable
AFFiNE.featureFlags.syncClientVersionCheck &&
version !== AFFiNE.version
) {
client.emit('server-version-rejected', {
currentVersion: version,
requiredVersion: AFFiNE.version,
reason: `Client version${
version ? ` ${version}` : ''
} is outdated, please update to ${AFFiNE.version}`,
});
throw new EventError(
EventErrorCode.VERSION_REJECTED,
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
);
}
}
async joinWorkspace(
client: Socket,
room: `${string}:${'sync' | 'awareness'}`
) {
await client.join(room);
}
async leaveWorkspace(
client: Socket,
room: `${string}:${'sync' | 'awareness'}`
) {
await client.leave(room);
}
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
if (!client.rooms.has(room)) {
throw new NotInWorkspaceError(room);
}
}
async assertWorkspaceAccessible(
workspaceId: string,
userId: string,
permission: Permission = Permission.Read
) {
if (
!(await this.permissions.isWorkspaceMember(
workspaceId,
userId,
permission
))
) {
throw new AccessDeniedError(workspaceId);
}
}
@Auth()
@SubscribeMessage('client-handshake-sync')
async handleClientHandshakeSync(
@CurrentUser() user: CurrentUser,
@MessageBody('workspaceId') workspaceId: string,
@MessageBody('version') version: string | undefined,
@CurrentUser() user: UserType,
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> {
this.assertVersion(client, version);
await this.assertWorkspaceAccessible(
const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId,
user.id,
Permission.Write
);
await this.joinWorkspace(client, Sync(workspaceId));
return {
data: {
clientId: client.id,
},
};
if (canWrite) {
await client.join(`${workspaceId}:sync`);
return {
data: {
clientId: client.id,
},
};
} else {
return {
error: new AccessDeniedError(workspaceId),
};
}
}
@Auth()
@SubscribeMessage('client-handshake-awareness')
async handleClientHandshakeAwareness(
@CurrentUser() user: CurrentUser,
@MessageBody('workspaceId') workspaceId: string,
@MessageBody('version') version: string | undefined,
@CurrentUser() user: UserType,
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> {
this.assertVersion(client, version);
await this.assertWorkspaceAccessible(
const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId,
user.id,
Permission.Write
);
await this.joinWorkspace(client, Awareness(workspaceId));
return {
data: {
clientId: client.id,
},
};
if (canWrite) {
await client.join(`${workspaceId}:awareness`);
return {
data: {
clientId: client.id,
},
};
} else {
return {
error: new AccessDeniedError(workspaceId),
};
}
}
/**
* @deprecated use `client-handshake-sync` and `client-handshake-awareness` instead
*/
@Auth()
@SubscribeMessage('client-handshake')
async handleClientHandShake(
@CurrentUser() user: UserType,
@MessageBody()
workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> {
const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId,
user.id,
Permission.Write
);
if (canWrite) {
await client.join([`${workspaceId}:sync`, `${workspaceId}:awareness`]);
return {
data: {
clientId: client.id,
},
};
} else {
return {
error: new AccessDeniedError(workspaceId),
};
}
}
@SubscribeMessage('client-leave-sync')
@@ -223,9 +202,14 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse> {
this.assertInWorkspace(client, Sync(workspaceId));
await this.leaveWorkspace(client, Sync(workspaceId));
return {};
if (client.rooms.has(`${workspaceId}:sync`)) {
await client.leave(`${workspaceId}:sync`);
return {};
} else {
return {
error: new NotInWorkspaceError(workspaceId),
};
}
}
@SubscribeMessage('client-leave-awareness')
@@ -233,23 +217,125 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse> {
this.assertInWorkspace(client, Awareness(workspaceId));
await this.leaveWorkspace(client, Awareness(workspaceId));
if (client.rooms.has(`${workspaceId}:awareness`)) {
await client.leave(`${workspaceId}:awareness`);
return {};
} else {
return {
error: new NotInWorkspaceError(workspaceId),
};
}
}
/**
* @deprecated use `client-leave-sync` and `client-leave-awareness` instead
*/
@SubscribeMessage('client-leave')
async handleClientLeave(
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse> {
if (client.rooms.has(`${workspaceId}:sync`)) {
await client.leave(`${workspaceId}:sync`);
}
if (client.rooms.has(`${workspaceId}:awareness`)) {
await client.leave(`${workspaceId}:awareness`);
}
return {};
}
@SubscribeMessage('client-pre-sync')
async loadDocStats(
@ConnectedSocket() client: Socket,
/**
* This is the old version of the `client-update` event without any data protocol.
* It only exists for backwards compatibility to adapt older clients.
*
* @deprecated
*/
@SubscribeMessage('client-update')
async handleClientUpdateV1(
@MessageBody()
{ workspaceId, timestamp }: { workspaceId: string; timestamp?: number }
): Promise<EventResponse<Record<string, number>>> {
this.assertInWorkspace(client, Sync(workspaceId));
{
workspaceId,
guid,
update,
}: {
workspaceId: string;
guid: string;
update: string;
},
@ConnectedSocket() client: Socket
) {
if (!client.rooms.has(`${workspaceId}:sync`)) {
this.logger.verbose(
`Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first`
);
return;
}
const stats = await this.docManager.getStats(workspaceId, timestamp);
const docId = new DocID(guid, workspaceId);
client
.to(`${docId.workspace}:sync`)
.emit('server-update', { workspaceId, guid, update });
// broadcast to all clients with newer version that only listen to `server-updates`
client
.to(`${docId.workspace}:sync`)
.emit('server-updates', { workspaceId, guid, updates: [update] });
const buf = Buffer.from(update, 'base64');
await this.docManager.push(docId.workspace, docId.guid, buf);
}
/**
* This is the old version of the `doc-load` event without any data protocol.
* It only exists for backwards compatibility to adapt older clients.
*
* @deprecated
*/
@Auth()
@SubscribeMessage('doc-load')
async loadDocV1(
@ConnectedSocket() client: Socket,
@CurrentUser() user: UserType,
@MessageBody()
{
workspaceId,
guid,
stateVector,
}: {
workspaceId: string;
guid: string;
stateVector?: string;
}
): Promise<{ missing: string; state?: string } | false> {
if (!client.rooms.has(`${workspaceId}:sync`)) {
const canRead = await this.permissions.tryCheckWorkspace(
workspaceId,
user.id
);
if (!canRead) {
return false;
}
}
const docId = new DocID(guid, workspaceId);
const doc = await this.docManager.get(docId.workspace, docId.guid);
if (!doc) {
return false;
}
const missing = Buffer.from(
encodeStateAsUpdate(
doc,
stateVector ? Buffer.from(stateVector, 'base64') : undefined
)
).toString('base64');
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
return {
data: stats,
missing,
state,
};
}
@@ -266,32 +352,33 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
updates: string[];
},
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
this.assertInWorkspace(client, Sync(workspaceId));
): Promise<EventResponse<{ accepted: true }>> {
if (!client.rooms.has(`${workspaceId}:sync`)) {
return {
error: new NotInWorkspaceError(workspaceId),
};
}
const docId = new DocID(guid, workspaceId);
const buffers = updates.map(update => Buffer.from(update, 'base64'));
const timestamp = await this.docManager.batchPush(
docId.workspace,
docId.guid,
buffers
);
client
.to(Sync(workspaceId))
.emit('server-updates', { workspaceId, guid, updates, timestamp });
.to(`${docId.workspace}:sync`)
.emit('server-updates', { workspaceId, guid, updates });
const buffers = updates.map(update => Buffer.from(update, 'base64'));
await this.docManager.batchPush(docId.workspace, docId.guid, buffers);
return {
data: {
accepted: true,
timestamp,
},
};
}
@Auth()
@SubscribeMessage('doc-load-v2')
async loadDocV2(
@ConnectedSocket() client: Socket,
@CurrentUser() user: UserType,
@MessageBody()
{
workspaceId,
@@ -303,7 +390,17 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
stateVector?: string;
}
): Promise<EventResponse<{ missing: string; state?: string }>> {
this.assertInWorkspace(client, Sync(workspaceId));
if (!client.rooms.has(`${workspaceId}:sync`)) {
const canRead = await this.permissions.tryCheckWorkspace(
workspaceId,
user.id
);
if (!canRead) {
return {
error: new AccessDeniedError(workspaceId),
};
}
}
const docId = new DocID(guid, workspaceId);
const doc = await this.docManager.get(docId.workspace, docId.guid);
@@ -335,28 +432,34 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ clientId: string }>> {
this.assertInWorkspace(client, Awareness(workspaceId));
client.to(Awareness(workspaceId)).emit('new-client-awareness-init');
return {
data: {
clientId: client.id,
},
};
if (client.rooms.has(`${workspaceId}:awareness`)) {
client.to(`${workspaceId}:awareness`).emit('new-client-awareness-init');
return {
data: {
clientId: client.id,
},
};
} else {
return {
error: new NotInWorkspaceError(workspaceId),
};
}
}
@SubscribeMessage('awareness-update')
async handleHelpGatheringAwareness(
@MessageBody()
{
workspaceId,
awarenessUpdate,
}: { workspaceId: string; awarenessUpdate: string },
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
@ConnectedSocket() client: Socket
): Promise<EventResponse> {
this.assertInWorkspace(client, Awareness(workspaceId));
client
.to(Awareness(workspaceId))
.emit('server-awareness-broadcast', { workspaceId, awarenessUpdate });
return {};
if (client.rooms.has(`${message.workspaceId}:awareness`)) {
client
.to(`${message.workspaceId}:awareness`)
.emit('server-awareness-broadcast', message);
return {};
} else {
return {
error: new NotInWorkspaceError(message.workspaceId),
};
}
}
}

View File

@@ -1,133 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { Quota_FreePlanV1_1 } from '../quota/schema';
@Injectable()
export class UserService {
defaultUserSelect = {
id: true,
name: true,
email: true,
emailVerifiedAt: true,
avatarUrl: true,
registered: true,
} satisfies Prisma.UserSelect;
constructor(private readonly prisma: PrismaClient) {}
get userCreatingData() {
return {
name: 'Unnamed',
features: {
create: {
reason: 'created by invite sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
};
}
async createUser(data: Prisma.UserCreateInput) {
return this.prisma.user.create({
data: {
...this.userCreatingData,
...data,
},
});
}
async createAnonymousUser(
email: string,
data?: Partial<Prisma.UserCreateInput>
) {
const user = await this.findUserByEmail(email);
if (user) {
throw new BadRequestException('Email already exists');
}
return this.createUser({
email,
name: email.split('@')[0],
...data,
});
}
async findUserById(id: string) {
return this.prisma.user
.findUnique({
where: { id },
select: this.defaultUserSelect,
})
.catch(() => {
return null;
});
}
async findUserByEmail(email: string) {
return this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
select: this.defaultUserSelect,
});
}
/**
* supposed to be used only for `Credential SignIn`
*/
async findUserWithHashedPasswordByEmail(email: string) {
return this.prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
}
async findOrCreateUser(
email: string,
data?: Partial<Prisma.UserCreateInput>
) {
const user = await this.findUserByEmail(email);
if (user) {
return user;
}
return this.createAnonymousUser(email, data);
}
async fulfillUser(
email: string,
data: Partial<
Pick<Prisma.UserCreateInput, 'emailVerifiedAt' | 'registered'>
>
) {
return this.prisma.user.upsert({
select: this.defaultUserSelect,
where: {
email,
},
update: data,
create: {
email,
...this.userCreatingData,
...data,
},
});
}
async deleteUser(id: string) {
return this.prisma.user.delete({ where: { id } });
}
}

View File

@@ -6,15 +6,15 @@ import { StorageModule } from '../storage';
import { UserAvatarController } from './controller';
import { UserManagementResolver } from './management';
import { UserResolver } from './resolver';
import { UserService } from './service';
import { UsersService } from './users';
@Module({
imports: [StorageModule, FeatureModule, QuotaModule],
providers: [UserResolver, UserManagementResolver, UserService],
providers: [UserResolver, UserManagementResolver, UsersService],
controllers: [UserAvatarController],
exports: [UserService],
exports: [UsersService],
})
export class UserModule {}
export class UsersModule {}
export { UserService } from './service';
export { UserType } from './types';
export { UsersService } from './users';

View File

@@ -6,21 +6,23 @@ import {
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { CurrentUser } from '../auth/current-user';
import { sessionUser } from '../auth/service';
import { Auth, CurrentUser } from '../auth/guard';
import { AuthService } from '../auth/service';
import { FeatureManagementService } from '../features';
import { UserService } from './service';
import { UserType } from './types';
import { UsersService } from './users';
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => UserType)
export class UserManagementResolver {
constructor(
private readonly users: UserService,
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly feature: FeatureManagementService
) {}
@@ -32,7 +34,7 @@ export class UserManagementResolver {
})
@Mutation(() => Int)
async addToEarlyAccess(
@CurrentUser() currentUser: CurrentUser,
@CurrentUser() currentUser: UserType,
@Args('email') email: string
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
@@ -42,9 +44,7 @@ export class UserManagementResolver {
if (user) {
return this.feature.addEarlyAccess(user.id);
} else {
const user = await this.users.createAnonymousUser(email, {
registered: false,
});
const user = await this.auth.createAnonymousUser(email);
return this.feature.addEarlyAccess(user.id);
}
}
@@ -57,7 +57,7 @@ export class UserManagementResolver {
})
@Mutation(() => Int)
async removeEarlyAccess(
@CurrentUser() currentUser: CurrentUser,
@CurrentUser() currentUser: UserType,
@Args('email') email: string
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
@@ -79,15 +79,13 @@ export class UserManagementResolver {
@Query(() => [UserType])
async earlyAccessUsers(
@Context() ctx: { isAdminQuery: boolean },
@CurrentUser() user: CurrentUser
@CurrentUser() user: UserType
): Promise<UserType[]> {
if (!this.feature.isStaff(user.email)) {
throw new ForbiddenException('You are not allowed to do this');
}
// allow query other user's subscription
ctx.isAdminQuery = true;
return this.feature.listEarlyAccess().then(users => {
return users.map(sessionUser);
});
return this.feature.listEarlyAccess();
}
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, UseGuards } from '@nestjs/common';
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
import {
Args,
Int,
@@ -7,49 +7,79 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PrismaClient, type User } from '@prisma/client';
import type { User } from '@prisma/client';
import { GraphQLError } from 'graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { isNil, omitBy } from 'lodash-es';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
PaymentRequiredException,
PrismaService,
Throttle,
} from '../../fundamentals';
import { CurrentUser } from '../auth/current-user';
import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service';
import { FeatureManagementService, FeatureType } from '../features';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { FeatureManagementService } from '../features';
import { QuotaService } from '../quota';
import { AvatarStorage } from '../storage';
import { UserService } from './service';
import {
DeleteAccount,
RemoveAvatar,
UpdateUserInput,
UserOrLimitedUser,
UserQuotaType,
UserType,
} from './types';
import { UsersService } from './users';
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => UserType)
export class UserResolver {
constructor(
private readonly prisma: PrismaClient,
private readonly prisma: PrismaService,
private readonly storage: AvatarStorage,
private readonly users: UserService,
private readonly users: UsersService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaService,
private readonly event: EventEmitter
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Publicable()
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
nullable: true,
})
async currentUser(@CurrentUser() user?: UserType) {
if (!user) {
return null;
}
const storedUser = await this.users.findUserById(user.id);
if (!storedUser) {
throw new BadRequestException(`User ${user.id} not found in db`);
}
return {
id: storedUser.id,
name: storedUser.name,
email: storedUser.email,
emailVerified: storedUser.emailVerified,
avatarUrl: storedUser.avatarUrl,
createdAt: storedUser.createdAt,
hasPassword: !!storedUser.password,
};
}
@Throttle({
default: {
limit: 10,
@@ -63,29 +93,32 @@ export class UserResolver {
})
@Public()
async user(
@CurrentUser() currentUser?: CurrentUser,
@CurrentUser() currentUser?: UserType,
@Args('email') email?: string
): Promise<typeof UserOrLimitedUser | null> {
) {
if (!email || !(await this.feature.canEarlyAccess(email))) {
throw new PaymentRequiredException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
return new GraphQLError(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
{
extensions: {
status: HttpStatus[HttpStatus.PAYMENT_REQUIRED],
code: HttpStatus.PAYMENT_REQUIRED,
},
}
);
}
// TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.users.findUserWithHashedPasswordByEmail(email);
const user = await this.users.findUserByEmail(email);
if (currentUser) return user;
// return empty response when user not exists
if (!user) return null;
if (currentUser) {
return sessionUser(user);
}
// only return limited info when not logged in
return {
email: user.email,
hasPassword: !!user.password,
email: user?.email,
hasPassword: !!user?.password,
};
}
@@ -102,21 +135,12 @@ export class UserResolver {
name: 'invoiceCount',
description: 'Get user invoice count',
})
async invoiceCount(@CurrentUser() user: CurrentUser) {
async invoiceCount(@CurrentUser() user: UserType) {
return this.prisma.userInvoice.count({
where: { userId: user.id },
});
}
@Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => [FeatureType], {
name: 'features',
description: 'Enabled features of a user',
})
async userFeatures(@CurrentUser() user: CurrentUser) {
return this.feature.getUserFeatures(user.id);
}
@Throttle({
default: {
limit: 10,
@@ -128,7 +152,7 @@ export class UserResolver {
description: 'Upload user avatar',
})
async uploadAvatar(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
@@ -152,33 +176,6 @@ export class UserResolver {
});
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => UserType, {
name: 'updateProfile',
})
async updateUserProfile(
@CurrentUser() user: CurrentUser,
@Args('input', { type: () => UpdateUserInput }) input: UpdateUserInput
): Promise<UserType> {
input = omitBy(input, isNil);
if (Object.keys(input).length === 0) {
return user;
}
return sessionUser(
await this.prisma.user.update({
where: { id: user.id },
data: input,
})
);
}
@Throttle({
default: {
limit: 10,
@@ -189,7 +186,7 @@ export class UserResolver {
name: 'removeAvatar',
description: 'Remove user avatar',
})
async removeAvatar(@CurrentUser() user: CurrentUser) {
async removeAvatar(@CurrentUser() user: UserType) {
if (!user) {
throw new BadRequestException(`User not found`);
}
@@ -207,9 +204,7 @@ export class UserResolver {
},
})
@Mutation(() => DeleteAccount)
async deleteAccount(
@CurrentUser() user: CurrentUser
): Promise<DeleteAccount> {
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
const deletedUser = await this.users.deleteUser(user.id);
this.event.emit('user.deleted', deletedUser);
return { success: true };

View File

@@ -1,15 +1,7 @@
import {
createUnionType,
Field,
ID,
InputType,
ObjectType,
} from '@nestjs/graphql';
import { createUnionType, Field, ID, ObjectType } from '@nestjs/graphql';
import type { User } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import { CurrentUser } from '../auth/current-user';
@ObjectType('UserQuotaHumanReadable')
export class UserQuotaHumanReadableType {
@Field({ name: 'name' })
@@ -50,7 +42,7 @@ export class UserQuotaType {
}
@ObjectType()
export class UserType implements CurrentUser {
export class UserType implements Partial<User> {
@Field(() => ID)
id!: string;
@@ -61,25 +53,19 @@ export class UserType implements CurrentUser {
email!: string;
@Field(() => String, { description: 'User avatar url', nullable: true })
avatarUrl!: string | null;
avatarUrl: string | null = null;
@Field(() => Boolean, {
description: 'User email verified',
})
emailVerified!: boolean;
@Field(() => Date, { description: 'User email verified', nullable: true })
emailVerified: Date | null = null;
@Field({ description: 'User created date', nullable: true })
createdAt!: Date;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword!: boolean | null;
@Field(() => Date, {
deprecationReason: 'useless',
description: 'User email verified',
nullable: true,
})
createdAt?: Date | null;
hasPassword?: boolean;
}
@ObjectType()
@@ -91,7 +77,7 @@ export class LimitedUserType implements Partial<User> {
description: 'User password has been set',
nullable: true,
})
hasPassword!: boolean | null;
hasPassword?: boolean;
}
export const UserOrLimitedUser = createUnionType({
@@ -115,9 +101,3 @@ export class RemoveAvatar {
@Field()
success!: boolean;
}
@InputType()
export class UpdateUserInput implements Partial<User> {
@Field({ description: 'User name', nullable: true })
name?: string;
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../fundamentals';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findUserByEmail(email: string) {
return this.prisma.user
.findUnique({
where: { email },
})
.catch(() => {
return null;
});
}
async findUserById(id: string) {
return this.prisma.user
.findUnique({
where: { id },
})
.catch(() => {
return null;
});
}
async deleteUser(id: string) {
return this.prisma.user.delete({ where: { id } });
}
}

View File

@@ -1,55 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import z from 'zod';
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`,
})
.max(20, { message: 'Password must be 20 or fewer charactors long' });
return z
.object({
email,
password,
})
.required();
}
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
const result = z.safeParse(value);
if (!result.success) {
const firstIssue = result.error.issues.at(0);
if (firstIssue) {
throw new BadRequestException(firstIssue.message);
} else {
throw new BadRequestException('Invalid credential');
}
}
}
export function assertValidEmail(email: string) {
assertValid(getAuthCredentialValidator().shape.email, email);
}
export function assertValidPassword(password: string) {
assertValid(getAuthCredentialValidator().shape.password, password);
}
export function assertValidCredential(credential: {
email: string;
password: string;
}) {
assertValid(getAuthCredentialValidator(), credential);
}
export const validators = {
assertValidEmail,
assertValidPassword,
assertValidCredential,
};

View File

@@ -7,13 +7,13 @@ import {
Param,
Res,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { Response } from 'express';
import { CallTimer } from '../../fundamentals';
import { CurrentUser, Public } from '../auth';
import { CallTimer, PrismaService } from '../../fundamentals';
import { Auth, CurrentUser, Publicable } from '../auth';
import { DocHistoryManager, DocManager } from '../doc';
import { WorkspaceBlobStorage } from '../storage';
import { UserType } from '../users';
import { DocID } from '../utils/doc';
import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types';
@@ -26,13 +26,12 @@ export class WorkspacesController {
private readonly permission: PermissionService,
private readonly docManager: DocManager,
private readonly historyManager: DocHistoryManager,
private readonly prisma: PrismaClient
private readonly prisma: PrismaService
) {}
// get workspace blob
//
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
@Public()
@Get('/:id/blobs/:name')
@CallTimer('controllers', 'workspace_get_blob')
async blob(
@@ -62,11 +61,12 @@ export class WorkspacesController {
}
// get doc binary
@Public()
@Get('/:id/docs/:guid')
@Auth()
@Publicable()
@CallTimer('controllers', 'workspace_get_doc')
async doc(
@CurrentUser() user: CurrentUser | undefined,
@CurrentUser() user: UserType | undefined,
@Param('id') ws: string,
@Param('guid') guid: string,
@Res() res: Response
@@ -111,9 +111,10 @@ export class WorkspacesController {
}
@Get('/:id/docs/:guid/histories/:timestamp')
@Auth()
@CallTimer('controllers', 'workspace_get_history')
async history(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Param('id') ws: string,
@Param('guid') guid: string,
@Param('timestamp') timestamp: string,

View File

@@ -4,7 +4,7 @@ import { DocModule } from '../doc';
import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
import { UserModule } from '../user';
import { UsersService } from '../users';
import { WorkspacesController } from './controller';
import { WorkspaceManagementResolver } from './management';
import { PermissionService } from './permission';
@@ -16,12 +16,13 @@ import {
} from './resolvers';
@Module({
imports: [DocModule, FeatureModule, QuotaModule, StorageModule, UserModule],
imports: [DocModule, FeatureModule, QuotaModule, StorageModule],
controllers: [WorkspacesController],
providers: [
WorkspaceResolver,
WorkspaceManagementResolver,
PermissionService,
UsersService,
PagePermissionResolver,
DocHistoryResolver,
WorkspaceBlobResolver,

View File

@@ -10,12 +10,14 @@ import {
} from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { CurrentUser } from '../auth';
import { Auth, CurrentUser } from '../auth';
import { FeatureManagementService, FeatureType } from '../features';
import { UserType } from '../users';
import { PermissionService } from './permission';
import { WorkspaceType } from './types';
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceManagementResolver {
constructor(
@@ -31,7 +33,7 @@ export class WorkspaceManagementResolver {
})
@Mutation(() => Int)
async addWorkspaceFeature(
@CurrentUser() currentUser: CurrentUser,
@CurrentUser() currentUser: UserType,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<number> {
@@ -50,7 +52,7 @@ export class WorkspaceManagementResolver {
})
@Mutation(() => Int)
async removeWorkspaceFeature(
@CurrentUser() currentUser: CurrentUser,
@CurrentUser() currentUser: UserType,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<boolean> {
@@ -69,7 +71,7 @@ export class WorkspaceManagementResolver {
})
@Query(() => [WorkspaceType])
async listWorkspaceFeatures(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<WorkspaceType[]> {
if (!this.feature.isStaff(user.email)) {
@@ -81,7 +83,7 @@ export class WorkspaceManagementResolver {
@Mutation(() => Boolean)
async setWorkspaceExperimentalFeature(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
@Args('enable') enable: boolean
@@ -115,7 +117,7 @@ export class WorkspaceManagementResolver {
complexity: 2,
})
async availableFeatures(
@CurrentUser() user: CurrentUser
@CurrentUser() user: UserType
): Promise<FeatureType[]> {
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
if (isEarlyAccessUser) {

View File

@@ -1,6 +1,7 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { type Prisma, PrismaClient } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../fundamentals';
import { Permission } from './types';
export enum PublicPageMode {
@@ -10,7 +11,7 @@ export enum PublicPageMode {
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaClient) {}
constructor(private readonly prisma: PrismaService) {}
/// Start regin: workspace permission
async get(ws: string, user: string) {
@@ -73,28 +74,6 @@ export class PermissionService {
return this.tryCheckPage(ws, id, user);
}
/**
* Returns whether a given user is a member of a workspace and has the given or higher permission.
*/
async isWorkspaceMember(
ws: string,
user: string,
permission: Permission
): Promise<boolean> {
const count = await this.prisma.workspaceUserPermission.count({
where: {
workspaceId: ws,
userId: user,
accepted: true,
type: {
gte: permission,
},
},
});
return count !== 0;
}
async checkWorkspace(
ws: string,
user?: string,
@@ -320,18 +299,6 @@ export class PermissionService {
return this.tryCheckWorkspace(ws, user, permission);
}
async isPublicPage(ws: string, page: string) {
return this.prisma.workspacePage
.count({
where: {
workspaceId: ws,
pageId: page,
public: true,
},
})
.then(count => count > 0);
}
async publishPage(ws: string, page: string, mode = PublicPageMode.Page) {
return this.prisma.workspacePage.upsert({
where: {
@@ -354,19 +321,26 @@ export class PermissionService {
}
async revokePublicPage(ws: string, page: string) {
return this.prisma.workspacePage.upsert({
const workspacePage = await this.prisma.workspacePage.findUnique({
where: {
workspaceId_pageId: {
workspaceId: ws,
pageId: page,
},
},
update: {
public: false,
});
if (!workspacePage) {
throw new Error('Page is not public');
}
return this.prisma.workspacePage.update({
where: {
workspaceId_pageId: {
workspaceId: ws,
pageId: page,
},
},
create: {
workspaceId: ws,
pageId: page,
data: {
public: false,
},
});

View File

@@ -1,9 +1,4 @@
import {
ForbiddenException,
Logger,
PayloadTooLargeException,
UseGuards,
} from '@nestjs/common';
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
import {
Args,
Int,
@@ -13,6 +8,7 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
@@ -22,14 +18,16 @@ import {
MakeCache,
PreventCache,
} from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { Auth, CurrentUser } from '../../auth';
import { FeatureManagementService, FeatureType } from '../../features';
import { QuotaManagementService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UserType } from '../../users';
import { PermissionService } from '../permission';
import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types';
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name);
@@ -45,7 +43,7 @@ export class WorkspaceBlobResolver {
complexity: 2,
})
async blobs(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
await this.permissions.checkWorkspace(workspace.id, user.id);
@@ -72,7 +70,7 @@ export class WorkspaceBlobResolver {
})
@MakeCache(['blobs'], ['workspaceId'])
async listBlobs(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissions.checkWorkspace(workspaceId, user.id);
@@ -88,7 +86,7 @@ export class WorkspaceBlobResolver {
@Query(() => WorkspaceBlobSizes, {
deprecationReason: 'use `user.storageUsage` instead',
})
async collectAllBlobSizes(@CurrentUser() user: CurrentUser) {
async collectAllBlobSizes(@CurrentUser() user: UserType) {
const size = await this.quota.getUserUsage(user.id);
return { size };
}
@@ -100,7 +98,7 @@ export class WorkspaceBlobResolver {
deprecationReason: 'no more needed',
})
async checkBlobSize(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('size', { type: () => SafeIntResolver }) blobSize: number
) {
@@ -119,7 +117,7 @@ export class WorkspaceBlobResolver {
@Mutation(() => String)
@PreventCache(['blobs'], ['workspaceId'])
async setBlob(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
@@ -140,7 +138,12 @@ export class WorkspaceBlobResolver {
const checkExceeded = (recvSize: number) => {
if (!storageQuota) {
throw new ForbiddenException('Cannot find user quota.');
throw new GraphQLError('cannot find user quota', {
extensions: {
status: HttpStatus[HttpStatus.FORBIDDEN],
code: HttpStatus.FORBIDDEN,
},
});
}
const total = usedSize + recvSize;
// only skip total storage check if workspace has unlimited feature
@@ -160,9 +163,12 @@ export class WorkspaceBlobResolver {
};
if (checkExceeded(0)) {
throw new PayloadTooLargeException(
'Storage or blob size limit exceeded.'
);
throw new GraphQLError('storage or blob size limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
});
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -174,7 +180,12 @@ export class WorkspaceBlobResolver {
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
if (checkExceeded(bufferSize)) {
reject(
new PayloadTooLargeException('Storage or blob size limit exceeded.')
new GraphQLError('storage or blob size limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
})
);
}
});
@@ -183,7 +194,14 @@ export class WorkspaceBlobResolver {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new PayloadTooLargeException('Storage limit exceeded.'));
reject(
new GraphQLError('storage limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
})
);
} else {
resolve(buffer);
}
@@ -197,7 +215,7 @@ export class WorkspaceBlobResolver {
@Mutation(() => Boolean)
@PreventCache(['blobs'], ['workspaceId'])
async deleteBlob(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('hash') name: string
) {

View File

@@ -13,8 +13,9 @@ import {
import type { SnapshotHistory } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { Auth, CurrentUser } from '../../auth';
import { DocHistoryManager } from '../../doc';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../permission';
import { Permission, WorkspaceType } from '../types';
@@ -67,9 +68,10 @@ export class DocHistoryResolver {
);
}
@Auth()
@Mutation(() => Date)
async recoverDoc(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('guid') guid: string,
@Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date

View File

@@ -1,4 +1,4 @@
import { BadRequestException, UseGuards } from '@nestjs/common';
import { ForbiddenException, UseGuards } from '@nestjs/common';
import {
Args,
Field,
@@ -9,13 +9,11 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
PrismaClient,
type WorkspacePage as PrismaWorkspacePage,
} from '@prisma/client';
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { CloudThrottlerGuard, PrismaService } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService, PublicPageMode } from '../permission';
import { Permission, WorkspaceType } from '../types';
@@ -41,10 +39,11 @@ class WorkspacePage implements Partial<PrismaWorkspacePage> {
}
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class PagePermissionResolver {
constructor(
private readonly prisma: PrismaClient,
private readonly prisma: PrismaService,
private readonly permission: PermissionService
) {}
@@ -88,7 +87,7 @@ export class PagePermissionResolver {
deprecationReason: 'renamed to publicPage',
})
async deprecatedSharePage(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
@@ -98,7 +97,7 @@ export class PagePermissionResolver {
@Mutation(() => WorkspacePage)
async publishPage(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string,
@Args({
@@ -112,7 +111,7 @@ export class PagePermissionResolver {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new BadRequestException('Expect page not to be workspace');
throw new ForbiddenException('Expect page not to be workspace');
}
await this.permission.checkWorkspace(
@@ -132,7 +131,7 @@ export class PagePermissionResolver {
deprecationReason: 'use revokePublicPage',
})
async deprecatedRevokePage(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
@@ -142,14 +141,14 @@ export class PagePermissionResolver {
@Mutation(() => WorkspacePage)
async revokePublicPage(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new BadRequestException('Expect page not to be workspace');
throw new ForbiddenException('Expect page not to be workspace');
}
await this.permission.checkWorkspace(
@@ -158,15 +157,6 @@ export class PagePermissionResolver {
Permission.Read
);
const isPublic = await this.permission.isPublicPage(
docId.workspace,
docId.guid
);
if (!isPublic) {
throw new BadRequestException('Page is not public');
}
return this.permission.revokePublicPage(docId.workspace, docId.guid);
}
}

View File

@@ -1,9 +1,8 @@
import {
ForbiddenException,
InternalServerErrorException,
HttpStatus,
Logger,
NotFoundException,
PayloadTooLargeException,
UseGuards,
} from '@nestjs/common';
import {
@@ -15,8 +14,9 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PrismaClient } from '@prisma/client';
import type { User } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import { GraphQLError } from 'graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
@@ -25,14 +25,14 @@ import {
EventEmitter,
type FileUpload,
MailService,
MutexService,
PrismaService,
Throttle,
TooManyRequestsException,
} from '../../../fundamentals';
import { CurrentUser, Public } from '../../auth';
import { Auth, CurrentUser, Public } from '../../auth';
import { AuthService } from '../../auth/service';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UserService, UserType } from '../../user';
import { UsersService, UserType } from '../../users';
import { PermissionService } from '../permission';
import {
InvitationType,
@@ -49,19 +49,20 @@ import { defaultWorkspaceAvatar } from '../utils';
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
private readonly logger = new Logger(WorkspaceResolver.name);
constructor(
private readonly auth: AuthService,
private readonly mailer: MailService,
private readonly prisma: PrismaClient,
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService,
private readonly users: UserService,
private readonly users: UsersService,
private readonly event: EventEmitter,
private readonly blobStorage: WorkspaceBlobStorage,
private readonly mutex: MutexService
private readonly blobStorage: WorkspaceBlobStorage
) {}
@ResolveField(() => Permission, {
@@ -69,7 +70,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async permission(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
@@ -160,7 +161,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async isOwner(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
@@ -172,7 +173,7 @@ export class WorkspaceResolver {
description: 'Get all accessible workspaces for current user',
complexity: 2,
})
async workspaces(@CurrentUser() user: CurrentUser) {
async workspaces(@CurrentUser() user: User) {
const data = await this.prisma.workspaceUserPermission.findMany({
where: {
userId: user.id,
@@ -216,7 +217,7 @@ export class WorkspaceResolver {
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissions.checkWorkspace(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
@@ -231,7 +232,7 @@ export class WorkspaceResolver {
description: 'Create a new workspace',
})
async createWorkspace(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
// we no longer support init workspace with a preload file
// use sync system to uploading them once created
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
@@ -289,7 +290,7 @@ export class WorkspaceResolver {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
@@ -304,10 +305,7 @@ export class WorkspaceResolver {
}
@Mutation(() => Boolean)
async deleteWorkspace(
@CurrentUser() user: CurrentUser,
@Args('id') id: string
) {
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissions.checkWorkspace(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
@@ -323,7 +321,7 @@ export class WorkspaceResolver {
@Mutation(() => String)
async invite(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('permission', { type: () => Permission }) permission: Permission,
@@ -339,87 +337,83 @@ export class WorkspaceResolver {
throw new ForbiddenException('Cannot change owner');
}
try {
// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.lock(lockFlag);
if (!lock) {
return new TooManyRequestsException('Server is busy');
}
// member limit check
const [memberCount, quota] = await Promise.all([
this.prisma.workspaceUserPermission.count({
where: { workspaceId },
}),
this.quota.getWorkspaceUsage(workspaceId),
]);
if (memberCount >= quota.memberLimit) {
throw new GraphQLError('Workspace member limit reached', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
});
}
// member limit check
const [memberCount, quota] = await Promise.all([
this.prisma.workspaceUserPermission.count({
where: { workspaceId },
}),
this.quota.getWorkspaceUsage(workspaceId),
]);
if (memberCount >= quota.memberLimit) {
return new PayloadTooLargeException('Workspace member limit reached.');
}
let target = await this.users.findUserByEmail(email);
if (target) {
const originRecord = await this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId,
userId: target.id,
},
});
// only invite if the user is not already in the workspace
if (originRecord) return originRecord.id;
} else {
target = await this.auth.createAnonymousUser(email);
}
let target = await this.users.findUserByEmail(email);
if (target) {
const originRecord =
await this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId,
userId: target.id,
},
});
// only invite if the user is not already in the workspace
if (originRecord) return originRecord.id;
} else {
target = await this.users.createAnonymousUser(email, {
registered: false,
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
}
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
target.id
);
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
target.id
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new InternalServerErrorException(
'Failed to send invite email. Please try again.'
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new GraphQLError(
'failed to send invite email, please try again',
{
extensions: {
status: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR],
code: HttpStatus.INTERNAL_SERVER_ERROR,
},
}
);
}
return inviteId;
} catch (e) {
this.logger.error('failed to invite user', e);
return new TooManyRequestsException('Server is busy');
}
return inviteId;
}
@Throttle({
@@ -488,7 +482,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async revoke(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
@@ -532,7 +526,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: CurrentUser,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('workspaceName') workspaceName: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean

View File

@@ -11,7 +11,7 @@ import {
import type { Workspace } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import { UserType } from '../user/types';
import { UserType } from '../users/types';
export enum Permission {
Read = 0,

View File

@@ -1,6 +1,6 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
@@ -25,7 +25,7 @@ export async function collectMigrations(): Promise<Migration[]> {
const migrations: Migration[] = await Promise.all(
migrationFiles.map(async file => {
return import(pathToFileURL(file).href).then(mod => {
return import(file).then(mod => {
const migration = mod[Object.keys(mod)[0]];
return {

View File

@@ -1,15 +1,13 @@
import { ModuleRef } from '@nestjs/core';
import { hash } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client';
import { UserService } from '../../core/user';
import { Config, CryptoHelper } from '../../fundamentals';
import { Config } from '../../fundamentals';
export class SelfHostAdmin1605053000403 {
// do the migration
static async up(_db: PrismaClient, ref: ModuleRef) {
static async up(db: PrismaClient, ref: ModuleRef) {
const config = ref.get(Config, { strict: false });
const crypto = ref.get(CryptoHelper, { strict: false });
const user = ref.get(UserService, { strict: false });
if (config.isSelfhosted) {
if (
!process.env.AFFINE_ADMIN_EMAIL ||
@@ -19,12 +17,13 @@ export class SelfHostAdmin1605053000403 {
'You have to set AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD environment variables to generate the initial user for self-hosted AFFiNE Server.'
);
}
await user.findOrCreateUser(process.env.AFFINE_ADMIN_EMAIL, {
name: 'AFFINE First User',
emailVerifiedAt: new Date(),
password: await crypto.encryptPassword(
process.env.AFFINE_ADMIN_PASSWORD
),
await db.user.create({
data: {
name: 'AFFINE First User',
email: process.env.AFFINE_ADMIN_EMAIL,
emailVerified: new Date(),
password: await hash(process.env.AFFINE_ADMIN_PASSWORD),
},
});
}
}

View File

@@ -1,39 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { loop } from './utils/loop';
export class Oauth1710319359062 {
// do the migration
static async up(db: PrismaClient) {
await loop(async (skip, take) => {
const oldRecords = await db.deprecatedNextAuthAccount.findMany({
skip,
take,
orderBy: {
providerAccountId: 'asc',
},
});
await db.connectedAccount.createMany({
data: oldRecords.map(record => ({
userId: record.userId,
provider: record.provider,
scope: record.scope,
providerAccountId: record.providerAccountId,
accessToken: record.access_token,
refreshToken: record.refresh_token,
expiresAt: record.expires_at
? new Date(record.expires_at * 1000)
: null,
})),
});
return oldRecords.length;
}, 10);
}
// revert the migration
static async down(db: PrismaClient) {
await db.connectedAccount.deleteMany({});
}
}

View File

@@ -1,13 +0,0 @@
export async function loop(
batchFn: (skip: number, take: number) => Promise<number>,
chunkSize: number = 100
) {
let turn = 0;
let last = chunkSize;
while (last === chunkSize) {
last = await batchFn(chunkSize * turn, chunkSize);
turn++;
}
}

View File

@@ -87,22 +87,6 @@ export interface AFFiNEConfig {
sync: boolean;
};
/**
* Application secrets for authentication and data encryption
*/
secrets: {
/**
* Application public key
*
*/
publicKey: string;
/**
* Application private key
*
*/
privateKey: string;
};
/**
* Deployment environment
*/
@@ -190,7 +174,6 @@ export interface AFFiNEConfig {
*/
featureFlags: {
earlyAccessPreview: boolean;
syncClientVersionCheck: boolean;
};
/**
@@ -220,32 +203,67 @@ export interface AFFiNEConfig {
* authentication config
*/
auth: {
session: {
/**
* Application auth expiration time in seconds
*
* @default 15 days
*/
ttl: number;
};
/**
* Application access token config
* Application access token expiration time
*/
accessToken: {
/**
* Application access token expiration time in seconds
*
* @default 7 days
*/
ttl: number;
/**
* Application refresh token expiration time in seconds
*
* @default 30 days
*/
refreshTokenTtl: number;
};
readonly accessTokenExpiresIn: number;
/**
* Application refresh token expiration time
*/
readonly refreshTokenExpiresIn: number;
/**
* Add some leeway (in seconds) to the exp and nbf validation to account for clock skew.
* Defaults to 60 if omitted.
*/
readonly leeway: number;
/**
* Application public key
*
*/
readonly publicKey: string;
/**
* Application private key
*
*/
readonly privateKey: string;
/**
* whether allow user to signup with email directly
*/
enableSignup: boolean;
/**
* whether allow user to signup by oauth providers
*/
enableOauth: boolean;
/**
* NEXTAUTH_SECRET
*/
nextAuthSecret: string;
/**
* all available oauth providers
*/
oauthProviders: Partial<
Record<
ExternalAccount,
{
enabled: boolean;
clientId: string;
clientSecret: string;
/**
* uri to start oauth flow
*/
authorizationUri?: string;
/**
* uri to authenticate `access_token` when user is redirected back from oauth provider with `code`
*/
accessTokenUri?: string;
/**
* uri to get user info with authenticated `access_token`
*/
userInfoUri?: string;
args?: Record<string, any>;
}
>
>;
captcha: {
/**
* whether to enable captcha

View File

@@ -3,6 +3,7 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { merge } from 'lodash-es';
import parse from 'parse-duration';
import pkg from '../../../package.json' assert { type: 'json' };
import {
@@ -22,9 +23,7 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
-----END EC PRIVATE KEY-----`;
const ONE_DAY_IN_SEC = 60 * 60 * 24;
const keyPair = (function () {
const jwtKeyPair = (function () {
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AUTH_PRIVATE_KEY),
@@ -115,13 +114,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
get deploy() {
return !this.node.dev && !this.node.test;
},
secrets: {
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
},
featureFlags: {
earlyAccessPreview: false,
syncClientVersionCheck: false,
},
https: false,
host: 'localhost',
@@ -150,13 +144,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
playground: true,
},
auth: {
session: {
ttl: 15 * ONE_DAY_IN_SEC,
},
accessToken: {
ttl: 7 * ONE_DAY_IN_SEC,
refreshTokenTtl: 30 * ONE_DAY_IN_SEC,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
accessTokenExpiresIn: parse('1h')! / 1000,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
refreshTokenExpiresIn: parse('7d')! / 1000,
leeway: 60,
captcha: {
enable: false,
turnstile: {
@@ -166,6 +158,14 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
bits: 20,
},
},
privateKey: jwtKeyPair.privateKey,
publicKey: jwtKeyPair.publicKey,
enableSignup: true,
enableOauth: false,
get nextAuthSecret() {
return this.privateKey;
},
oauthProviders: {},
},
storage: getDefaultAFFiNEStorageConfig(),
rateLimiter: {
@@ -187,10 +187,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
enabled: false,
},
plugins: {
enabled: new Set(),
enabled: [],
use(plugin, config) {
this[plugin] = merge(this[plugin], config || {});
this.enabled.add(plugin);
this.enabled.push(plugin);
},
},
} satisfies AFFiNEConfig;

View File

@@ -1,34 +1,37 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { S3ClientConfigType } from '@aws-sdk/client-s3';
export type StorageProviderType = 'fs' | 'r2' | 's3';
export interface FsStorageConfig {
path: string;
}
export type R2StorageConfig = S3ClientConfigType & {
accountId: string;
};
export type S3StorageConfig = S3ClientConfigType;
export interface StorageProvidersConfig {
fs: FsStorageConfig;
}
export type StorageProviderType = keyof StorageProvidersConfig;
export type StorageConfig<Ext = unknown> = {
export type StorageTargetConfig<Ext = unknown> = {
provider: StorageProviderType;
bucket: string;
} & Ext;
export interface StoragesConfig {
avatar: StorageConfig<{ publicLinkFactory: (key: string) => string }>;
blob: StorageConfig;
}
export interface AFFiNEStorageConfig {
/**
* All providers for object storage
*
* Support different providers for different usage at the same time.
*/
providers: StorageProvidersConfig;
storages: StoragesConfig;
providers: {
fs?: FsStorageConfig;
s3?: S3StorageConfig;
r2?: R2StorageConfig;
};
storages: {
avatar: StorageTargetConfig<{ publicLinkFactory: (key: string) => string }>;
blob: StorageTargetConfig;
};
}
export type StorageProviders = AFFiNEStorageConfig['providers'];

View File

@@ -1,2 +0,0 @@
export * from './payment-required';
export * from './too-many-requests';

View File

@@ -1,10 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class PaymentRequiredException extends HttpException {
constructor(desc?: string, code: string = 'Payment Required') {
super(
HttpException.createBody(desc ?? code, code, HttpStatus.PAYMENT_REQUIRED),
HttpStatus.PAYMENT_REQUIRED
);
}
}

View File

@@ -1,14 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class TooManyRequestsException extends HttpException {
constructor(desc?: string, code: string = 'Too Many Requests') {
super(
HttpException.createBody(
desc ?? code,
code,
HttpStatus.TOO_MANY_REQUESTS
),
HttpStatus.TOO_MANY_REQUESTS
);
}
}

View File

@@ -17,19 +17,17 @@ export type PathType<T, Path extends string> = string extends Path
: unknown
: unknown;
export type Leaves<T, P extends string = ''> =
T extends Payload<any>
? P
: T extends Record<string, any>
? {
[K in keyof T]: K extends string ? Leaves<T[K], Join<P, K>> : never;
}[keyof T]
: never;
export type Flatten<T> =
Leaves<T> extends infer R
export type Leaves<T, P extends string = ''> = T extends Payload<any>
? P
: T extends Record<string, any>
? {
// @ts-expect-error yo, ts can't make it
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
}
[K in keyof T]: K extends string ? Leaves<T[K], Join<P, K>> : never;
}[keyof T]
: never;
export type Flatten<T> = Leaves<T> extends infer R
? {
// @ts-expect-error yo, ts can't make it
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
}
: never;

View File

@@ -3,20 +3,13 @@ import { fileURLToPath } from 'node:url';
import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Global, HttpException, HttpStatus, Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { Request, Response } from 'express';
import { GraphQLError } from 'graphql';
import { Config } from '../config';
import { GQLLoggerPlugin } from './logger-plugin';
export type GraphqlContext = {
req: Request;
res: Response;
isAdminQuery: boolean;
};
@Global()
@Module({
imports: [
@@ -36,48 +29,12 @@ export type GraphqlContext = {
: '../../../schema.gql'
),
sortSchema: true,
context: ({
req,
res,
}: {
req: Request;
res: Response;
}): GraphqlContext => ({
context: ({ req, res }: { req: Request; res: Response }) => ({
req,
res,
isAdminQuery: false,
}),
includeStacktraceInErrorResponses: !config.node.prod,
plugins: [new GQLLoggerPlugin()],
formatError: (formattedError, error) => {
// @ts-expect-error allow assign
formattedError.extensions ??= {};
if (
error instanceof GraphQLError &&
error.originalError instanceof HttpException
) {
const statusCode = error.originalError.getStatus();
const statusName = HttpStatus[statusCode];
// originally be 'INTERNAL_SERVER_ERROR'
formattedError.extensions['code'] = statusCode;
formattedError.extensions['status'] = statusName;
delete formattedError.extensions['originalError'];
return formattedError;
} else {
// @ts-expect-error allow assign
formattedError.message = 'Internal Server Error';
formattedError.extensions['code'] =
HttpStatus.INTERNAL_SERVER_ERROR;
formattedError.extensions['status'] =
HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR];
}
return formattedError;
},
};
},
inject: [Config],

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