Compare commits

..

42 Commits

Author SHA1 Message Date
Tom Moor fa7f8d3592 fix: ExportDocumentTreeTask needs documentStructure 2025-05-07 19:38:30 -04:00
codegen-sh[bot] c598c61afe Add PromQL as a code highlighting option in the editor (#9146)
* Add PromQL as a code highlighting option in the editor

* Update code.ts

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-07 19:11:47 -04:00
Tom Moor 68b07eb466 fix: withoutState scope should include state as fallback (#9145) 2025-05-07 18:49:33 -04:00
Tom Moor 06a149407a fix: withoutState scope should include state as fallback (#9144) 2025-05-07 09:00:42 -04:00
Tom Moor b9387734c7 perf: Remove documentStructure from default query select (#9141)
* perf: Remove documentStructure from default query select

* test
2025-05-07 07:47:57 -04:00
dependabot[bot] 810b7908e4 chore(deps): bump the aws group with 5 updates (#9136)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.797.0` | `3.802.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.797.0` | `3.802.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.797.0` | `3.802.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.796.0` | `3.800.0` |


Updates `@aws-sdk/client-s3` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.797.0 to 3.802.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.802.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.796.0 to 3.800.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.800.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.802.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.800.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 08:02:14 -04:00
dependabot[bot] 6b76a898fa chore(deps): bump the babel group with 9 updates (#9139)
Bumps the babel group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.26.10` | `7.27.1` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.9` | `7.27.1` |
| [@babel/plugin-transform-regenerator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-regenerator) | `7.27.0` | `7.27.1` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.26.9` | `7.27.1` |
| [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) | `7.26.3` | `7.27.1` |
| [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli) | `7.27.0` | `7.27.1` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.27.0` | `7.27.1` |


Updates `@babel/core` from 7.26.10 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-destructuring)

Updates `@babel/plugin-transform-regenerator` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-regenerator)

Updates `@babel/preset-env` from 7.26.9 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-env)

Updates `@babel/preset-react` from 7.26.3 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-react)

Updates `@babel/cli` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-cli)

Updates `@babel/preset-typescript` from 7.27.0 to 7.27.1
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-regenerator"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-react"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-version: 7.27.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:41 -04:00
dependabot[bot] 8ba83e2173 chore(deps): bump react-medium-image-zoom from 5.2.13 to 5.2.14 (#9137)
Bumps [react-medium-image-zoom](https://github.com/rpearce/react-medium-image-zoom) from 5.2.13 to 5.2.14.
- [Release notes](https://github.com/rpearce/react-medium-image-zoom/releases)
- [Changelog](https://github.com/rpearce/react-medium-image-zoom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rpearce/react-medium-image-zoom/compare/v5.2.13...v5.2.14)

---
updated-dependencies:
- dependency-name: react-medium-image-zoom
  dependency-version: 5.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:27 -04:00
dependabot[bot] 5a4b8c5faa chore(deps): bump validator and @types/validator (#9138)
Bumps [validator](https://github.com/validatorjs/validator.js) and [@types/validator](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/validator). These dependencies needed to be updated together.

Updates `validator` from 13.12.0 to 13.15.0
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.12.0...13.15.0)

Updates `@types/validator` from 13.12.1 to 13.15.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/validator)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/validator"
  dependency-version: 13.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 21:05:14 -04:00
Tom Moor 3f8bdf7ac2 Refactor withMembershipScope (#9134) 2025-05-04 18:37:01 -04:00
Tom Moor 9c4b4f4989 fix: Chained scopes overwrite (#9133) 2025-05-04 22:16:38 +00:00
Hemachandar c5d534b2ad Add script to resolve existing collection index collisions (#8810)
* Add script to resolve existing collection index collisions

* Remove debug logging

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-05-04 16:12:09 -04:00
Tom Moor bed3d1078e fix: More guards against empty text nodes (#9132) 2025-05-04 20:11:02 +00:00
Tom Moor 83e87254c6 fix: Invisible JS error (#9131) 2025-05-04 13:59:21 +00:00
Tom Moor f576ddccfe chore: Add 10 more brand icons (#9130) 2025-05-04 13:18:37 +00:00
Tom Moor 0a674eacfa fix: Improve behavior when hitting backspace/delete with table cell selections (#9129) 2025-05-04 08:51:48 -04:00
Tom Moor ceac57bd64 Pick collection color based on existing collections (#9128) 2025-05-04 03:09:08 +00:00
Tom Moor 97f31e3f2a fix: Cannot create document through @mention on collection overview (#9127) 2025-05-03 22:13:54 -04:00
Tom Moor a06671e8ce OAuth provider (#8884)
This PR contains the necessary work to make Outline an OAuth provider including:

- OAuth app registration
- OAuth app management
- Private / public apps (Public in cloud only)
- Full OAuth 2.0 spec compatible authentication flow
- Granular scopes
- User token management screen in settings
- Associated API endpoints for programatic access
2025-05-03 19:40:18 -04:00
Tom Moor fd3c21d28b Remove withCollectionPermissions scope (#9124)
* Remove withCollectionPermissions scope

* defaultScopeWithUser -> withUserScope

* fix: Include withDrafts in groupMemberships.list

* rename
2025-05-03 12:00:54 -04:00
Tom Moor c0c36bacbb fix: Error loading collection (#9123) 2025-05-03 02:18:56 +00:00
Tom Moor 7bd1ea7c40 chore/attachments-sw-cache (#9122) 2025-05-02 22:15:39 -04:00
Tom Moor 5ebb1e8a61 feat: Add input rule to create new tables (#9118) 2025-05-02 08:19:57 -04:00
Tom Moor 96d6987858 fix: Mobile toolbar overlaps with home indicator (#9119) 2025-05-02 08:19:48 -04:00
Tom Moor 3602198cd8 fix: Subtle collection loading bug (#9120) 2025-05-02 08:19:41 -04:00
dependabot[bot] 00bab31cff chore(deps): bump vite from 6.3.3 to 6.3.4 (#9112)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.3 to 6.3.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-30 18:16:38 -04:00
Tom Moor 3ef2b7cf42 fix: Backlinks should be ordered alphabetically (#9106) 2025-04-30 02:17:03 +00:00
Tom Moor 18743da2fc fix: bold inline code marks cause formatting to split (#9105)
* fix: Inline code mark split around bold

* Show inline formatting options + code in toolbar
2025-04-30 01:50:52 +00:00
dependabot[bot] fe1307d7e7 chore(deps): bump the aws group with 5 updates (#9086)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.787.0` | `3.797.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.787.0` | `3.797.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.787.0` | `3.797.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.787.0` | `3.797.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.787.0` | `3.796.0` |


Updates `@aws-sdk/client-s3` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.787.0 to 3.797.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.797.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.787.0 to 3.796.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.796.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.797.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.796.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-29 06:48:04 -04:00
codegen-sh[bot] a226889143 Update task scheduling to use instance method (#9092)
* Update task scheduling to use instance method

* Delete update_task_schedule.sh

* Applied automatic fixes

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-29 06:47:51 -04:00
Tom Moor 347f033802 fix: Notifications received for draft with access but no subscription (#9099) 2025-04-29 06:45:15 -04:00
Tom Moor f5c659f902 fix: Prevent cross-domain websocket connections to on-premise instances (#9064) 2025-04-28 17:27:40 -04:00
Hemachandar 722d10e7de Implement type-safe schedule method for tasks (#9079)
* Implement type-safe task scheduler

* introduce 'schedule' instance method

* typo
2025-04-28 17:27:24 -04:00
Hemachandar ce001547b5 fix: Check pasted text is url before creating an URL object (#9082) 2025-04-28 17:27:12 -04:00
dependabot[bot] 8d05e2b095 chore(deps): bump pg from 8.14.1 to 8.15.6 (#9084)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.14.1 to 8.15.6.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.15.6/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.15.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 17:26:54 -04:00
dependabot[bot] 19e40cf814 chore(deps-dev): bump nodemon from 3.1.9 to 3.1.10 (#9085)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: nodemon
  dependency-version: 3.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 17:26:28 -04:00
dependabot[bot] 2bb9b50637 chore(deps): bump react-portal from 4.2.2 to 4.3.0 (#9087)
Bumps [react-portal](https://github.com/tajo/react-portal) from 4.2.2 to 4.3.0.
- [Release notes](https://github.com/tajo/react-portal/releases)
- [Commits](https://github.com/tajo/react-portal/commits)

---
updated-dependencies:
- dependency-name: react-portal
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 17:26:18 -04:00
Tom Moor 4885612661 Switch Linear to actor=app method (#9074) 2025-04-27 15:01:23 +00:00
Tom Moor e2dd6221f8 Extract subdomain auth redirect (#9070)
* Extract subdomain auth redirect

* docs
2025-04-27 10:55:05 -04:00
Hemachandar 7f513a6950 fix: Store Linear workspace logo only when it's available (#9072) 2025-04-27 09:26:36 -04:00
Tom Moor 6440d78b6f fix: Double fetch on refactored paginated list (#9068) 2025-04-26 21:35:41 +00:00
Tom Moor 7e05fc1017 Revert "Add recency boost to search results (#9038)" (#9065)
This reverts commit 2bc47cfcef.
2025-04-26 16:44:49 +00:00
126 changed files with 2702 additions and 1859 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
@@ -44,7 +44,7 @@ export const switchTeam = createAction({
section: TeamSection,
visible: ({ stores }) =>
!!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1,
children: createTeamsList,
children: switchTeamsList,
});
export const createTeam = createAction({
+23 -5
View File
@@ -1,3 +1,4 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
@@ -18,6 +19,7 @@ import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -30,6 +32,26 @@ export interface FormData {
permission: CollectionPermission | undefined;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = React.useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
@@ -42,11 +64,7 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const iconColor = useIconColor(collection);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
+13 -7
View File
@@ -195,21 +195,27 @@ const PaginatedList = <T extends PaginatedItem>({
}
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
React.useEffect(() => {
void fetchResults();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prevFetch = usePrevious(fetch);
const prevOptions = usePrevious(options);
// Equivalent to componentDidUpdate
// Initial fetch on mount
React.useEffect(() => {
if (fetch) {
void fetchResults();
}
}, [fetch]);
// Handle updates to fetch or options
React.useEffect(() => {
if (!prevFetch || !prevOptions) {
return; // Skip on initial mount since it's handled by the above effect
}
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
reset();
void fetchResults();
}
}, [fetch, options, reset, prevFetch, prevOptions, fetchResults]);
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
// Computed property equivalent
const itemsToRender = React.useMemo(
+3 -3
View File
@@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import TeamMenu from "~/menus/TeamMenu";
import { homePath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
@@ -62,7 +62,7 @@ function AppSidebar() {
<DndProvider backend={HTML5Backend} options={html5Options}>
<DragPlaceholder />
<OrganizationMenu>
<TeamMenu>
{(props: SidebarButtonProps) => (
<SidebarButton
{...props}
@@ -91,7 +91,7 @@ function AppSidebar() {
</Tooltip>
</SidebarButton>
)}
</OrganizationMenu>
</TeamMenu>
<Overflow>
<Section>
<SidebarLink
+1
View File
@@ -321,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
padding-left: var(--sal);
${fadeOnDesktopBackgrounded()}
@media print {
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const collection = collectionId ? collections.get(collectionId) : undefined;
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId
star.documentId ?? star.collectionId ?? ""
);
const [expanded, setExpanded] = useState(
(star.documentId
+6 -1
View File
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { getSafeAreaInsets } from "@shared/utils/browser";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useEventListener from "~/hooks/useEventListener";
@@ -241,12 +242,16 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
if (props.active) {
const rect = document.body.getBoundingClientRect();
const safeAreaInsets = getSafeAreaInsets();
return (
<ReactPortal>
<MobileWrapper
ref={menuRef}
style={{
bottom: `calc(100% - ${height - rect.y}px)`,
bottom: `calc(100% - ${
height - rect.y - safeAreaInsets.bottom
}px)`,
}}
>
{props.children}
+3 -2
View File
@@ -6,6 +6,7 @@ import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import { isUrl } from "@shared/utils/urls";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -29,9 +30,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
+2 -1
View File
@@ -2,7 +2,8 @@ import Extension from "@shared/editor/lib/Extension";
import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+3 -3
View File
@@ -67,7 +67,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+B`,
icon: <BoldIcon />,
active: isMarkActive(schema.marks.strong),
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
name: "em",
@@ -75,7 +75,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+I`,
icon: <ItalicIcon />,
active: isMarkActive(schema.marks.em),
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
name: "strikethrough",
@@ -83,7 +83,7 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+D`,
icon: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.mark,
+14
View File
@@ -0,0 +1,14 @@
import { getCookie } from "tiny-cookie";
export type Sessions = Record<
string,
{
name: string;
logoUrl: string;
url: string;
}
>;
export function useLoggedInSessions(): Sessions {
return JSON.parse(getCookie("sessions") || "{}");
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
if (makeRequestOnMount) {
void request();
}
}, [request, makeRequestOnMount]);
}, []);
return { data, loading, loaded, error, request };
}
@@ -10,7 +10,7 @@ import {
} from "~/actions/definitions/navigation";
import {
createTeam,
createTeamsList,
switchTeamsList,
desktopLoginTeam,
} from "~/actions/definitions/teams";
import useActionContext from "~/hooks/useActionContext";
@@ -22,7 +22,7 @@ type Props = {
children?: React.ReactNode;
};
const OrganizationMenu: React.FC = ({ children }: Props) => {
const TeamMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
unstable_offset: [4, -4],
placement: "bottom-start",
@@ -44,7 +44,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
// menu is not cached at all.
const actions = React.useMemo(
() => [
...createTeamsList(context),
...switchTeamsList(context),
createTeam,
desktopLoginTeam,
separator(),
@@ -64,4 +64,4 @@ const OrganizationMenu: React.FC = ({ children }: Props) => {
);
};
export default observer(OrganizationMenu);
export default observer(TeamMenu);
+10
View File
@@ -331,6 +331,16 @@ export default class Document extends ArchivableModel implements Searchable {
);
}
/**
* Returns the documents that link to this document.
*
* @returns documents that link to this document
*/
@computed
get backlinks(): Document[] {
return this.store.getBacklinkedDocuments(this.id);
}
/**
* Returns users that have been individually given access to the document.
*
+1 -1
View File
@@ -22,7 +22,7 @@ class Star extends Model {
document?: Document;
/** The collection ID that is starred. */
collectionId: string;
collectionId?: string;
/** The collection that is starred. */
@Relation(() => Collection, { onDelete: "cascade" })
+1 -1
View File
@@ -19,7 +19,7 @@ class OAuthAuthentication extends ParanoidModel {
userId: string;
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId">;
oauthClient: Pick<OAuthClient, "id" | "name" | "clientId" | "avatarUrl">;
oauthClientId: string;
-3
View File
@@ -29,7 +29,6 @@ const Drafts = lazy(() => import("~/scenes/Drafts"));
const Home = lazy(() => import("~/scenes/Home"));
const Search = lazy(() => import("~/scenes/Search"));
const Trash = lazy(() => import("~/scenes/Trash"));
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
const RedirectDocument = ({
match,
@@ -53,8 +52,6 @@ function AuthenticatedRoutes() {
return (
<Switch>
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
<WebsocketProvider>
<AuthenticatedLayout>
<React.Suspense
+8 -6
View File
@@ -6,14 +6,15 @@ import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazyWithRetry from "~/utils/lazyWithRetry";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const Authenticated = lazyWithRetry(() => import("~/components/Authenticated"));
const AuthenticatedRoutes = lazyWithRetry(() => import("./authenticated"));
const SharedDocument = lazyWithRetry(() => import("~/scenes/Document/Shared"));
const Login = lazyWithRetry(() => import("~/scenes/Login"));
const Logout = lazyWithRetry(() => import("~/scenes/Logout"));
const Authenticated = lazy(() => import("~/components/Authenticated"));
const AuthenticatedRoutes = lazy(() => import("./authenticated"));
const SharedDocument = lazy(() => import("~/scenes/Document/Shared"));
const Login = lazy(() => import("~/scenes/Login"));
const Logout = lazy(() => import("~/scenes/Logout"));
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
export default function Routes() {
useQueryNotices();
@@ -43,6 +44,7 @@ export default function Routes() {
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
@@ -6,15 +6,18 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
import { Properties } from "~/types";
const extensions = withUIExtensions(richExtensions);
@@ -22,8 +25,8 @@ type Props = {
collection: Collection;
};
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
function Overview({ collection }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
@@ -54,6 +57,24 @@ function CollectionDescription({ collection }: Props) {
[childOffsetHeight]
);
const onCreateLink = React.useCallback(
async (params: Properties<Document>) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: true,
}
);
return newDocument.url;
},
[collection, documents]
);
return (
<>
{collections.isSaving && <LoadingIndicator />}
@@ -65,6 +86,7 @@ function CollectionDescription({ collection }: Props) {
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
@@ -83,4 +105,4 @@ const Placeholder = styled(Text)`
min-height: 27px;
`;
export default observer(CollectionDescription);
export default observer(Overview);
+8 -14
View File
@@ -20,7 +20,6 @@ import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
@@ -46,6 +45,7 @@ import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -66,7 +66,6 @@ const CollectionScene = observer(function _CollectionScene() {
const location = useLocation();
const { t } = useTranslation();
const { documents, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
@@ -120,21 +119,16 @@ const CollectionScene = observer(function _CollectionScene() {
React.useEffect(() => {
async function fetchData() {
if ((!can || !collection) && !error && !isFetching) {
try {
setError(undefined);
setFetching(true);
await collections.fetch(id);
} catch (err) {
setError(err);
} finally {
setFetching(false);
}
try {
setError(undefined);
await collections.fetch(id);
} catch (err) {
setError(err);
}
}
void fetchData();
}, [collections, isFetching, collection, error, id, can]);
}, []);
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
@@ -265,7 +259,7 @@ const CollectionScene = observer(function _CollectionScene() {
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<CollectionDescription collection={collection} />
<Overview collection={collection} />
) : (
<Redirect
to={{
@@ -18,7 +18,7 @@ type Props = {
};
function References({ document }: Props) {
const { collections, documents } = useStores();
const { documents } = useStores();
const user = useCurrentUser();
const location = useLocation();
const locationSidebarContext = useLocationSidebarContext();
@@ -27,10 +27,8 @@ function References({ document }: Props) {
void documents.fetchBacklinks(document.id);
}, [documents, document.id]);
const backlinks = documents.getBacklinkedDocuments(document.id);
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const backlinks = document.backlinks;
const collection = document.collection;
const children = collection
? collection.getChildrenForDocument(document.id)
: [];
+7 -6
View File
@@ -42,9 +42,10 @@ import { getRedirectUrl, navigateToSubdomain } from "./urls";
type Props = {
children?: (config?: Config) => React.ReactNode;
onBack?: () => void;
};
function Login({ children }: Props) {
function Login({ children, onBack }: Props) {
const location = useLocation();
const query = useQuery();
const notice = query.get("notice");
@@ -110,7 +111,7 @@ function Login({ children }: Props) {
if (error) {
return (
<Background>
<BackButton />
<BackButton onBack={onBack} />
<ChangeLanguage locale={detectLanguage()} />
<Centered>
<PageTitle title={t("Login")} />
@@ -142,7 +143,7 @@ function Login({ children }: Props) {
if (isCloudHosted && isCustomDomain && !config.name) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered>
<PageTitle title={t("Custom domain setup")} />
@@ -160,7 +161,7 @@ function Login({ children }: Props) {
if (Desktop.isElectron() && notice === "domain-required") {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered as="form" onSubmit={handleGoSubdomain}>
@@ -199,7 +200,7 @@ function Login({ children }: Props) {
if (emailLinkSentTo) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<Centered>
<PageTitle title={t("Check your email")} />
<CheckEmailIcon size={38} />
@@ -234,7 +235,7 @@ function Login({ children }: Props) {
return (
<Background>
<BackButton config={config} />
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered gap={12}>
+51 -39
View File
@@ -1,23 +1,51 @@
import { MoreIcon } from "outline-icons";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { AvatarVariant } from "~/components/Avatar/Avatar";
import { parseDomain } from "@shared/utils/domains";
import type OAuthClient from "~/models/oauth/OAuthClient";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
import Heading from "~/components/Heading";
import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLoggedInSessions } from "~/hooks/useLoggedInSessions";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import { client } from "~/utils/ApiClient";
import { BadRequestError, NotFoundError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import Login from "./Login";
import { OAuthScopeHelper } from "./OAuthScopeHelper";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { ConnectHeader } from "./components/ConnectHeader";
import { TeamSwitcher } from "./components/TeamSwitcher";
export default function OAuthAuthorize() {
const team = useCurrentTeam({ rejectOnEmpty: false });
const sessions = useLoggedInSessions();
// We're self-hosted or on a team subdomain already, just show the authorize screen.
if (team) {
return <Authorize />;
}
// Cloud hosted and on root domain show the workspace switcher.
const isAppRoot =
parseDomain(window.location.hostname).host === parseDomain(env.URL).host;
const hasLoggedInSessions = Object.keys(sessions).length > 0;
if (isCloudHosted && hasLoggedInSessions && isAppRoot) {
return <TeamSwitcher sessions={sessions} />;
}
return <Login />;
}
/**
* Authorize component is responsible for handling the OAuth authorization process.
@@ -40,19 +68,17 @@ function Authorize() {
scope,
} = Object.fromEntries(params);
const [scopes] = React.useState(() => scope?.split(" ") ?? []);
const {
error: clientError,
data: response,
request,
} = useRequest(() => client.post("/oauthClients.info", { clientId }));
React.useEffect(() => {
if (clientId) {
void request();
}
}, []);
const { error: clientError, data: response } = useRequest<{
data: OAuthClient;
}>(() => client.post("/oauthClients.info", { clientId, redirectUri }), true);
const handleCancel = () => {
if (redirectUri && !clientError) {
const url = new URL(redirectUri);
url.searchParams.set("error", "access_denied");
window.location.href = url.toString();
return;
}
if (window.history.length) {
window.history.back();
} else {
@@ -77,6 +103,7 @@ function Authorize() {
!redirectUri && "redirect_uri",
!responseType && "response_type",
!scope && "scope",
!state && "state",
].filter(Boolean);
if (missingParams.length || clientError) {
@@ -84,13 +111,20 @@ function Authorize() {
<Background>
<Centered>
<StyledHeading>{t("An error occurred")}</StyledHeading>
{clientError ? (
{clientError instanceof NotFoundError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be found, please check the provided client ID"
)}
<Pre>{clientId}</Pre>
</Text>
) : clientError instanceof BadRequestError ? (
<Text as="p" type="secondary">
{t(
"The OAuth client could not be loaded, please check the redirect URI is valid"
)}
<Pre>{redirectUri}</Pre>
</Text>
) : (
<Text as="p" type="secondary">
{t("Required OAuth parameters are missing")}
@@ -117,30 +151,10 @@ function Authorize() {
return (
<Background>
<ChangeLanguage locale={detectLanguage()} />
<PageTitle title={t("Authorize")} />
<Centered gap={12}>
<Text type="tertiary">
<Flex gap={12} align="center">
<Avatar
variant={AvatarVariant.Square}
model={{
avatarUrl: response.data.avatarUrl,
initial: response.data.name[0],
}}
size={AvatarSize.XXLarge}
alt={response.data.name}
/>
<MoreIcon />
<Avatar
variant={AvatarVariant.Square}
model={team}
size={AvatarSize.XXLarge}
alt={team.name}
/>
</Flex>
</Text>
<ConnectHeader team={team} oauthClient={response.data} />
<StyledHeading>
{t(`{{ appName }} wants to access {{ teamName }}`, {
appName: name,
@@ -244,5 +258,3 @@ const Pre = styled.pre`
font-size: 12px;
white-space: pre-wrap;
`;
export default Authorize;
+10 -1
View File
@@ -10,12 +10,21 @@ import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
config?: Config;
onBack?: () => void;
};
export function BackButton({ config }: Props) {
export function BackButton({ onBack, config }: Props) {
const { t } = useTranslation();
const isSubdomain = !!config?.hostname;
if (onBack) {
return (
<Link onClick={onBack}>
<BackIcon /> {t("Back")}
</Link>
);
}
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
return null;
}
@@ -0,0 +1,40 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import Flex from "@shared/components/Flex";
import Text from "@shared/components/Text";
import type Team from "~/models/Team";
import type OAuthClient from "~/models/oauth/OAuthClient";
import { Avatar } from "~/components/Avatar";
import { AvatarSize, AvatarVariant } from "~/components/Avatar/Avatar";
type Props = {
team: Team;
oauthClient: OAuthClient;
};
export function ConnectHeader({ team, oauthClient }: Props) {
return (
<Text type="tertiary">
<Flex gap={12} align="center">
<Avatar
variant={AvatarVariant.Square}
model={{
avatarUrl: oauthClient.avatarUrl,
initial: oauthClient.name[0],
}}
size={AvatarSize.XXLarge}
alt={oauthClient.name}
/>
<MoreIcon />
<Avatar
variant={AvatarVariant.Square}
model={team}
size={AvatarSize.XXLarge}
alt={team.name}
/>
</Flex>
</Text>
);
}
@@ -0,0 +1,109 @@
import { ArrowIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { s } from "@shared/styles";
import { AvatarSize } from "~/components/Avatar";
import Avatar, { AvatarVariant } from "~/components/Avatar/Avatar";
import ChangeLanguage from "~/components/ChangeLanguage";
import Heading from "~/components/Heading";
import OutlineIcon from "~/components/Icons/OutlineIcon";
import env from "~/env";
import type { Sessions } from "~/hooks/useLoggedInSessions";
import { detectLanguage } from "~/utils/language";
import Login from "../Login";
import { Background } from "./Background";
import { Centered } from "./Centered";
type Props = { sessions: Sessions };
export function TeamSwitcher({ sessions }: Props) {
const { t } = useTranslation();
const [showLogin, setShowLogin] = React.useState(false);
const url = new URL(window.location.href);
const appName = env.APP_NAME;
if (showLogin) {
return <Login onBack={() => setShowLogin(false)} />;
}
return (
<Background>
<ChangeLanguage locale={detectLanguage()} />
<Centered>
<OutlineIcon size={AvatarSize.XXLarge} />
<StyledHeading>{t("Choose a workspace")}</StyledHeading>
<Text type="tertiary" as="p">
{t(
"Choose an {{ appName }} workspace or login to continue connecting this app",
{ appName }
)}
.
</Text>
{Object.keys(sessions)?.map((teamId) => {
const session = sessions[teamId];
const location = session.url + url.pathname + url.search;
return (
<TeamLink href={location} key={session.url}>
<Avatar
variant={AvatarVariant.Square}
model={{
avatarUrl: session.logoUrl,
initial: session.name[0],
}}
size={AvatarSize.Large}
alt={session.name}
/>
{session.name}
<StyledArrowIcon />
</TeamLink>
);
})}
<TeamLink onClick={() => setShowLogin(true)}>
<ArrowIcon size={AvatarSize.Large} />
{t("Login to workspace")}
</TeamLink>
</Centered>
</Background>
);
}
const StyledArrowIcon = styled(ArrowIcon)`
position: absolute;
transition: all 0.2s ease-in-out;
opacity: 0;
right: 12px;
`;
const TeamLink = styled.a`
position: relative;
left: -8px;
right: -8px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin: 4px;
border-radius: 8px;
width: 100%;
color: ${s("text")};
font-weight: ${s("fontWeightMedium")};
&:hover {
background: ${s("listItemHoverBackground")};
${StyledArrowIcon} {
opacity: 1;
right: 8px;
}
}
`;
const StyledHeading = styled(Heading).attrs({
as: "h2",
centered: true,
})`
margin-top: 0;
`;
+6 -2
View File
@@ -80,11 +80,16 @@ const Application = observer(function Application({ oauthClient }: Props) {
async (data: FormData) => {
try {
await oauthClient.save(data);
toast.success(
oauthClient.published
? t("Application published")
: t("Application updated")
);
} catch (error) {
toast.error(error.message);
}
},
[oauthClient]
[oauthClient, t]
);
const handleRotateSecret = React.useCallback(async () => {
@@ -173,7 +178,6 @@ const Application = observer(function Application({ oauthClient }: Props) {
<Input
type="text"
{...register("description", {
required: true,
maxLength: OAuthClientValidation.maxDescriptionLength,
})}
flex
+7
View File
@@ -186,6 +186,13 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
);
}
@computed
get archived(): Collection[] {
return orderBy(this.orderedData, "archivedAt", "desc").filter(
+2 -2
View File
@@ -300,8 +300,8 @@ export default class DocumentsStore extends Store<Document> {
const documentIds = this.backlinks.get(documentId) || [];
return orderBy(
compact(documentIds.map((id) => this.data.get(id))),
"updatedAt",
"desc"
"title",
"asc"
);
}
+1 -1
View File
@@ -154,7 +154,7 @@ export default class RootStore {
private getStoreNameForModelName(modelName: string) {
for (const key of Object.keys(this)) {
const store = this[key as keyof RootStore];
if ("modelName" in store && store.modelName === modelName) {
if (store && "modelName" in store && store.modelName === modelName) {
return key as keyof RootStore;
}
}
+22 -21
View File
@@ -48,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@aws-sdk/client-s3": "3.803.0",
"@aws-sdk/lib-storage": "3.803.0",
"@aws-sdk/s3-presigned-post": "3.803.0",
"@aws-sdk/s3-request-presigner": "3.803.0",
"@aws-sdk/signature-v4-crt": "^3.803.0",
"@babel/core": "^7.27.1",
"@babel/plugin-proposal-decorators": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -175,7 +175,7 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.14.1",
"pg": "^8.15.6",
"pg-tsquery": "^8.4.2",
"pluralize": "^8.0.0",
"png-chunks-extract": "^1.0.0",
@@ -208,9 +208,9 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.13",
"react-medium-image-zoom": "5.2.14",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-portal": "^4.3.0",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
@@ -228,6 +228,7 @@
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
"sequelize-strict-attributes": "^1.0.2",
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
@@ -248,9 +249,9 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"validator": "13.15.0",
"vaul": "^1.1.2",
"vite": "^6.3.3",
"vite": "^6.3.4",
"vite-plugin-pwa": "^0.21.2",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -262,8 +263,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@babel/cli": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
@@ -328,7 +329,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.12.1",
"@types/validator": "^13.15.0",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@@ -357,7 +358,7 @@
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^13.3.0",
"nodemon": "^3.1.9",
"nodemon": "^3.1.10",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
+12 -33
View File
@@ -1,13 +1,12 @@
import Router from "koa-router";
import find from "lodash/find";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration, Team } from "@server/models";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { GitHubUtils } from "../../shared/GitHubUtils";
import { GitHub } from "../github";
@@ -17,10 +16,17 @@ const router = new Router();
router.get(
"github.callback",
auth({
optional: true,
}),
auth({ optional: true }),
validate(T.GitHubCallbackSchema),
apexAuthRedirect<T.GitHubCallbackReq>({
getTeamId: (ctx) => ctx.input.query.state,
getRedirectPath: (ctx, team) =>
GitHubUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => GitHubUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.GitHubCallbackReq>) => {
const {
@@ -43,33 +49,6 @@ router.get(
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
GitHubUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
}
const client = await GitHub.authenticateAsUser(code!, teamId);
const installationsByUser = await client.requestAppInstallations();
const installation = find(
+18 -45
View File
@@ -1,11 +1,10 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration, Team } from "@server/models";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { Linear } from "../linear";
import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask";
@@ -20,49 +19,21 @@ router.get(
optional: true,
}),
validate(T.LinearCallbackSchema),
apexAuthRedirect<T.LinearCallbackReq>({
getTeamId: (ctx) => LinearUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
LinearUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => LinearUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.LinearCallbackReq>) => {
const { code, state, error } = ctx.input.query;
const { code, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let parsedState;
try {
parsedState = LinearUtils.parseState(state);
} catch {
ctx.redirect(LinearUtils.errorUrl("invalid_state"));
return;
}
const { teamId } = parsedState;
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
LinearUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(LinearUtils.errorUrl("unauthenticated"));
}
}
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(LinearUtils.errorUrl(error));
@@ -107,10 +78,12 @@ router.get(
);
transaction.afterCommit(async () => {
await UploadLinearWorkspaceLogoTask.schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
if (workspace.logoUrl) {
await new UploadLinearWorkspaceLogoTask().schedule({
integrationId: integration.id,
logoUrl: workspace.logoUrl,
});
}
});
ctx.redirect(LinearUtils.successUrl());
+1 -1
View File
@@ -46,7 +46,7 @@ export class LinearUtils {
scope: this.oauthScopes,
response_type: "code",
prompt: "consent",
actor: "application",
actor: "app",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
+12 -41
View File
@@ -1,11 +1,10 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Integration, IntegrationAuthentication, Team } from "@server/models";
import { Integration, IntegrationAuthentication } from "@server/models";
import { APIContext } from "@server/types";
import { NotionClient } from "../notion";
import * as T from "./schema";
@@ -17,49 +16,21 @@ router.get(
"notion.callback",
auth({ optional: true }),
validate(T.NotionCallbackSchema),
apexAuthRedirect<T.NotionCallbackReq>({
getTeamId: (ctx) => NotionUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
NotionUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => NotionUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.NotionCallbackReq>) => {
const { code, state, error } = ctx.input.query;
const { code, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let parsedState;
try {
parsedState = NotionUtils.parseState(state);
} catch {
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
return;
}
const { teamId } = parsedState;
// This code block accounts for the root domain being unable to access authentication for subdomains.
// We must forward to the appropriate subdomain to complete the oauth flow.
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
NotionUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
}
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(NotionUtils.errorUrl(error));
@@ -66,6 +66,6 @@ export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.
protected async scheduleTask(
importTask: ImportTask<IntegrationService.Notion>
): Promise<void> {
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
}
}
@@ -77,7 +77,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
protected async scheduleNextTask(
importTask: ImportTask<IntegrationService.Notion>
) {
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
await new NotionAPIImportTask().schedule({ importTaskId: importTask.id });
return;
}
+20 -20
View File
@@ -288,7 +288,7 @@ export class NotionConverter {
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text,
text: item.plain_text || item.mention.link_mention.href,
marks: [
{
type: "link",
@@ -302,7 +302,7 @@ export class NotionConverter {
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text,
text: item.plain_text || item.mention.link_preview.url,
marks: [
{
type: "link",
@@ -314,14 +314,14 @@ export class NotionConverter {
};
}
if (!item.plain_text) {
return undefined;
if (item.plain_text) {
return {
type: "text",
text: item.plain_text,
};
}
return {
type: "text",
text: item.plain_text,
};
return undefined;
}
if (item.type === "equation") {
@@ -336,20 +336,20 @@ export class NotionConverter {
};
}
if (!item.text.content) {
return undefined;
if (item.text.content) {
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
}
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
return undefined;
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
-3
View File
@@ -227,9 +227,6 @@ router.post(
const options = {
query: text,
limit: 5,
searchConfig: {
boostRecent: true,
},
};
if (!user) {
+11 -27
View File
@@ -4,16 +4,15 @@ import Router from "koa-router";
import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import accountProvisioner from "@server/commands/accountProvisioner";
import { ValidationError } from "@server/errors";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
import validate from "@server/middlewares/validate";
import {
IntegrationAuthentication,
Integration,
Team,
User,
Collection,
} from "@server/models";
@@ -126,6 +125,15 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
"slack.post",
auth({ optional: true }),
validate(T.SlackPostSchema),
apexAuthRedirect<T.SlackPostReq>({
getTeamId: (ctx) => SlackUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
SlackUtils.connectUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => SlackUtils.errorUrl("unauthenticated"),
}),
async (ctx: APIContext<T.SlackPostReq>) => {
const { code, error, state } = ctx.input.query;
const { user } = ctx.state.auth;
@@ -144,31 +152,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
throw ValidationError("Invalid state");
}
const { teamId, collectionId, type } = parsedState;
// This code block accounts for the root domain being unable to access authentication for
// subdomains. We must forward to the appropriate subdomain to complete the OAuth flow.
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
SlackUtils.connectUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
}
}
const { collectionId, type } = parsedState;
switch (type) {
case IntegrationType.Post: {
@@ -29,8 +29,12 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalled();
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
event,
subscriptionId: subscription.id,
});
@@ -53,7 +57,9 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledTimes(0);
});
it("it schedules a delivery for the event for each subscription", async () => {
@@ -79,13 +85,21 @@ describe("WebhookProcessor", () => {
await processor.perform(event);
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalled();
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledTimes(2);
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
event,
subscriptionId: subscription.id,
});
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
expect(
jest.mocked(DeliverWebhookTask.prototype.schedule)
).toHaveBeenCalledWith({
event,
subscriptionId: subscriptionTwo.id,
});
@@ -24,7 +24,10 @@ export default class WebhookProcessor extends BaseProcessor {
await Promise.all(
applicableSubscriptions.map((subscription) =>
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
new DeliverWebhookTask().schedule({
event,
subscriptionId: subscription.id,
})
)
);
}
@@ -237,6 +237,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "imports.delete":
// Ignored
return;
case "oauthClients.create":
case "oauthClients.update":
case "oauthClients.delete":
// Ignored
return;
default:
assertUnreachable(event);
}
+19 -30
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -22,8 +21,8 @@ type Props = {
type Result = {
document: Document;
share?: Share;
collection?: Collection | null;
share: Share | null;
collection: Collection | null;
};
export default async function loadDocument({
@@ -33,9 +32,9 @@ export default async function loadDocument({
user,
includeState,
}: Props): Promise<Result> {
let document;
let collection;
let share;
let document: Document | null = null;
let collection: Collection | null = null;
let share: Share | null = null;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
@@ -72,20 +71,7 @@ export default async function loadDocument({
where: whereClause,
include: [
{
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
model: Document.scope("withDrafts"),
required: true,
as: "document",
},
@@ -129,14 +115,13 @@ export default async function loadDocument({
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
// Cannot use document.collection here as it does not include the
// documentStructure by default through the relationship.
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
}
return {
@@ -155,11 +140,15 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
}
invariant(collection, "collection not found");
if (!collection.sharing) {
if (!collection?.sharing) {
throw AuthorizationError();
}
+18 -12
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { createContext } from "@server/context";
import { traceFunction } from "@server/logging/tracing";
@@ -24,7 +23,7 @@ type Props = {
/** Position of moved document within document structure */
index?: number;
/** The IP address of the user moving the document */
ip: string;
ip: string | null;
/** The database transaction to run within */
transaction?: Transaction;
};
@@ -66,16 +65,21 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.findByPk(document.collectionId!, {
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.findByPk(collectionId, {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -144,12 +148,14 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
newCollection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId, {
transaction,
rejectOnEmpty: true,
});
invariant(newCollection, "Collection not found");
result.collections.push(newCollection);
@@ -4,9 +4,11 @@ import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { buildAttachment, buildDocument } from "@server/test/factories";
import documentPermanentDeleter from "./documentPermanentDeleter";
jest.mock("@server/queues/tasks/DeleteAttachmentTask", () => ({
schedule: jest.fn(),
}));
jest.mock("@server/queues/tasks/DeleteAttachmentTask");
beforeEach(() => {
jest.resetAllMocks();
});
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
@@ -60,7 +62,9 @@ describe("documentPermanentDeleter", () => {
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(DeleteAttachmentTask.schedule).toHaveBeenCalledTimes(2);
expect(
jest.mocked(DeleteAttachmentTask.prototype.schedule)
).toHaveBeenCalledTimes(2);
expect(
await Document.unscoped().count({
where: {
+1 -1
View File
@@ -67,7 +67,7 @@ export default async function documentPermanentDeleter(documents: Document[]) {
"commands",
`Attachment ${attachmentId} scheduled for deletion`
);
await DeleteAttachmentTask.schedule({
await new DeleteAttachmentTask().schedule({
attachmentId,
teamId: document.teamId,
});
+1 -1
View File
@@ -56,5 +56,5 @@ export default async function userSuspender({
}
);
await CleanupDemotedUserTask.schedule({ userId: user.id });
await new CleanupDemotedUserTask().schedule({ userId: user.id });
}
+54
View File
@@ -0,0 +1,54 @@
import { Next } from "koa";
import { parseDomain } from "@shared/utils/domains";
import { Team } from "@server/models";
import { APIContext } from "@server/types";
/**
* An authentication middleware that should be used on routes that return from external auth flows
* to the apex domain. In these cases the user will be redirected to the correct subdomain where
* they are authenticated.
*
* @param options Options for the middleware
* @returns Koa middleware function
*/
export default function apexAuthRedirect<T>({
getTeamId,
getRedirectPath,
getErrorPath,
}: {
/** Get the team ID for the current request */
getTeamId: (ctx: APIContext<T>) => string | null | undefined;
/** Get the redirect URL for the given team ID */
getRedirectPath: (ctx: APIContext<T>, team: Team) => string;
/** Get the error URL for the current request */
getErrorPath: (ctx: APIContext<T>) => string;
}) {
return async function apexAuthRedirectMiddleware(
ctx: APIContext<T>,
next: Next
) {
const { user } = ctx.state.auth;
if (user) {
return next();
}
const teamId = getTeamId(ctx);
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
attributes: ["id", "subdomain"],
rejectOnEmpty: true,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(getRedirectPath(ctx, team));
} catch (err) {
return ctx.redirect(getErrorPath(ctx));
}
} else {
return ctx.redirect(getErrorPath(ctx));
}
};
}
@@ -0,0 +1,29 @@
"use strict";
const { execFileSync } = require("child_process");
const path = require("path");
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up() {
if (
process.env.NODE_ENV === "test" ||
process.env.DEPLOYMENT === "hosted"
) {
return;
}
const scriptName = path.basename(__filename);
const scriptPath = path.join(
process.cwd(),
"build",
`server/scripts/${scriptName}`
);
execFileSync("node", [scriptPath], { stdio: "inherit" });
},
async down() {
// noop
},
};
@@ -160,7 +160,7 @@ module.exports = {
},
lastActiveAt: {
type: Sequelize.DATE,
allowNull: false
allowNull: true
},
scope: {
type: Sequelize.ARRAY(Sequelize.STRING),
+35 -29
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
});
describe("#url", () => {
test("should return correct url for the collection", () => {
it("should return correct url for the collection", () => {
const collection = new Collection({
id: "1234",
});
@@ -25,7 +25,7 @@ describe("#url", () => {
});
describe("getDocumentParents", () => {
test("should return array of parent document ids", async () => {
it("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
expect(result ? result[0] : undefined).toBe(parent.id);
});
test("should return array of parent document ids", async () => {
it("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
expect(result?.length).toBe(0);
});
test("should not error if documentStructure is empty", async () => {
it("should not error if documentStructure is empty", async () => {
const parent = await buildDocument();
await buildDocument();
const collection = await buildCollection();
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
});
describe("getDocumentTree", () => {
test("should return document tree", async () => {
it("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [await document.toNavigationNode()],
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
);
});
test("should return nested documents in tree", async () => {
it("should return nested documents in tree", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
});
describe("#addDocumentToStructure", () => {
test("should add as last element without index", async () => {
it("should add as last element without index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure!.length).toBe(1);
});
test("should add with an index", async () => {
it("should add with an index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].id).toBe(id);
});
test("should add as a child if with parent", async () => {
it("should add as a child if with parent", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
test("should add as a child if with parent with index", async () => {
it("should add as a child if with parent with index", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
test("should add the document along with its nested document(s)", async () => {
it("should add the document along with its nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
);
});
test("should add the document along with its archived nested document(s)", async () => {
it("should add the document along with its archived nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
);
});
describe("options: documentJson", () => {
test("should append supplied json over document's own", async () => {
it("should append supplied json over document's own", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
});
describe("#updateDocument", () => {
test("should update root document's data", async () => {
it("should update root document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
expect(collection.documentStructure![0].title).toBe("Updated title");
});
test("should update child document's data", async () => {
it("should update child document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
newDocument.title = "Updated title";
await newDocument.save();
await collection.updateDocument(newDocument);
const reloaded = await Collection.findByPk(collection.id);
const reloaded = await collection.reload();
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
});
describe("#removeDocument", () => {
test("should save if removing", async () => {
it("should save if removing", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
expect(collection.save).toBeCalled();
});
test("should remove documents from root", async () => {
it("should remove documents from root", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
test("should remove a document with child documents", async () => {
it("should remove a document with child documents", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
test("should remove a child document", async () => {
it("should remove a child document", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(newDocument);
const reloaded = await Collection.findByPk(collection.id);
const reloaded = await collection.reload();
expect(reloaded!.documentStructure!.length).toBe(1);
expect(reloaded!.documentStructure![0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
});
describe("#membershipUserIds", () => {
test("should return collection and group memberships", async () => {
it("should return collection and group memberships", async () => {
const team = await buildTeam();
const teamId = team.id;
// Make 6 users
@@ -464,47 +464,53 @@ describe("#membershipUserIds", () => {
});
describe("#findByPk", () => {
test("should return collection with collection Id", async () => {
it("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present", async () => {
it("should not return documentStructure by default", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(() => response!.documentStructure).toThrow();
});
it("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
test("should return collection when urlId is present, but missing slug", async () => {
it("should return collection when urlId is present, but missing slug", async () => {
const collection = await buildCollection();
const id = collection.urlId;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
test("should return null when incorrect uuid type", async () => {
it("should return null when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(null);
});
test("should return null when incorrect urlId length", async () => {
it("should return null when incorrect urlId length", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
});
test("should return null when no collection is found with uuid", async () => {
it("should return null when no collection is found with uuid", async () => {
const response = await Collection.findByPk(
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
);
expect(response).toBe(null);
});
test("should return null when no collection is found with urlId", async () => {
it("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
+13
View File
@@ -37,6 +37,7 @@ import {
AllowNull,
BeforeCreate,
BeforeUpdate,
DefaultScope,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -69,6 +70,11 @@ type AdditionalFindOptions = {
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["documentStructure"],
},
}))
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -121,6 +127,12 @@ type AdditionalFindOptions = {
},
],
}),
withDocumentStructure: () => ({
attributes: {
// resets to include the documentStructure column
exclude: [],
},
}),
withMembership: (userId: string) => {
if (!userId) {
return {};
@@ -238,6 +250,7 @@ class Collection extends ParanoidModel<
@Column
maintainerApprovalRequired: boolean;
@Default(null)
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
+2 -5
View File
@@ -11,7 +11,6 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -96,10 +95,8 @@ describe("#delete", () => {
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
document.reload({ paranoid: false }),
collection.reload(),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
+71 -56
View File
@@ -13,9 +13,9 @@ import {
Transaction,
Op,
FindOptions,
ScopeOptions,
WhereOptions,
EmptyResultError,
Sequelize,
} from "sequelize";
import {
ForeignKey,
@@ -72,12 +72,20 @@ import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
// If content (JSON) is null then we still need to return the state column (BINARY)
// as it's used as a fallback for content deserialization for older documents.
// This can be removed if content is 100% backfilled.
const stateIfContentEmpty = Sequelize.literal(
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
);
type AdditionalFindOptions = {
userId?: string;
includeState?: boolean;
rejectOnEmpty?: boolean | Error;
};
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@DefaultScope(() => ({
include: [
{
@@ -102,27 +110,14 @@ type AdditionalFindOptions = {
},
},
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
}))
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@Scopes(() => ({
withCollectionPermissions: (userId: string, paranoid = true) => ({
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
],
}),
withoutState: {
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
},
withCollection: {
@@ -136,7 +131,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
exclude: [],
include: [],
},
},
withDrafts: {
@@ -169,13 +164,25 @@ type AdditionalFindOptions = {
],
};
},
withMembership: (userId: string) => {
withMembership: (userId: string, paranoid = true) => {
if (!userId) {
return {};
}
return {
include: [
{
model: userId
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
: Collection,
as: "collection",
paranoid,
},
{
association: "memberships",
where: {
@@ -419,10 +426,13 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.findByPk(model.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
if (!collection) {
return;
}
@@ -443,7 +453,9 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.findByPk(model.collectionId!, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -637,21 +649,19 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
static defaultScopeWithUser(userId: string) {
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", userId],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", userId],
};
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", userId],
};
static withMembershipScope(
userId: string,
options?: FindOptions<Document> & { includeDrafts?: boolean }
) {
return this.scope([
"defaultScope",
collectionScope,
viewScope,
membershipScope,
options?.includeDrafts ? "withDrafts" : "defaultScope",
"withoutState",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, options?.paranoid],
},
]);
}
@@ -685,14 +695,12 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
options.includeState ? "withState" : "withoutState",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId],
method: ["withMembership", userId, rest.paranoid],
},
]);
@@ -750,9 +758,6 @@ class Document extends ArchivableModel<
const user = userId ? await User.findByPk(userId) : null;
const documents = await this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
@@ -938,7 +943,9 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1005,10 +1012,13 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
: undefined;
if (collection) {
@@ -1039,10 +1049,13 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
: undefined;
if (collection) {
@@ -1063,7 +1076,7 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.findByPk(collectionId, {
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1115,7 +1128,9 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
+1 -1
View File
@@ -408,7 +408,7 @@ class Team extends ParanoidModel<
});
if (attachment) {
await DeleteAttachmentTask.schedule({
await new DeleteAttachmentTask().schedule({
attachmentId: attachment.id,
teamId: model.id,
});
+1 -1
View File
@@ -717,7 +717,7 @@ class User extends ParanoidModel<
});
if (attachment) {
await DeleteAttachmentTask.schedule({
await new DeleteAttachmentTask().schedule({
attachmentId: attachment.id,
teamId: model.teamId,
});
@@ -1,7 +1,9 @@
import { NotificationEventType } from "@shared/types";
import { DocumentPermission, NotificationEventType } from "@shared/types";
import { UserMembership } from "@server/models";
import {
buildComment,
buildDocument,
buildDraftDocument,
buildSubscription,
buildUser,
} from "@server/test/factories";
@@ -54,6 +56,78 @@ describe("NotificationHelper", () => {
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread in draft", async () => {
const documentAuthor = await buildUser();
// create a draft
const document = await buildDraftDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
collectionId: null,
});
// add a bunch of users as direct members
const user = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const user2 = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
const user3 = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.CreateComment]: true },
});
await UserMembership.create({
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
await UserMembership.create({
documentId: document.id,
userId: user2.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
await UserMembership.create({
documentId: document.id,
userId: user3.id,
permission: DocumentPermission.Read,
createdById: user.id,
});
// Add a subscription for only one of those users
await Promise.all([
buildSubscription({
userId: user.id,
}),
buildSubscription({
userId: user2.id,
}),
buildSubscription({
userId: user3.id,
documentId: document.id,
}),
]);
const comment = await buildComment({
documentId: document.id,
userId: documentAuthor.id,
});
const recipients =
await NotificationHelper.getCommentNotificationRecipients(
document,
comment,
comment.createdById
);
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(user3.id);
});
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
+10 -4
View File
@@ -193,10 +193,16 @@ export default class NotificationHelper {
[Op.ne]: actorId,
},
event: SubscriptionType.Document,
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
...(document.collectionId
? {
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
}
: {
documentId: document.id,
}),
},
include: [
{
+50 -238
View File
@@ -1,25 +1,23 @@
import { describe, expect } from "@jest/globals";
import { subMonths } from "date-fns";
import { DocumentPermission, StatusFilter } from "@shared/types";
import SearchHelper from "@server/models/helpers/SearchHelper";
import {
buildTeam,
buildUser,
buildCollection,
buildDocument,
buildDraftDocument,
buildCollection,
buildTeam,
buildUser,
buildShare,
} from "@server/test/factories";
import UserMembership from "../UserMembership";
import SearchHelper from "./SearchHelper";
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
describe("SearchHelper", () => {
describe("#searchForTeam", () => {
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
it("should return search results from public collections", async () => {
test("should return search results from public collections", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
@@ -36,7 +34,7 @@ describe("SearchHelper", () => {
expect(results[0].document?.id).toBe(document.id);
});
it("should return search results from a collection without search term", async () => {
test("should return search results from a collection without search term", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
@@ -60,7 +58,7 @@ describe("SearchHelper", () => {
);
});
it("should not return results from private collections without providing collectionId", async () => {
test("should not return results from private collections without providing collectionId", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
@@ -77,7 +75,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(0);
});
it("should return results from private collections when collectionId is provided", async () => {
test("should return results from private collections when collectionId is provided", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
@@ -95,7 +93,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(1);
});
it("should return results from document tree of shared document", async () => {
test("should return results from document tree of shared document", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
@@ -125,7 +123,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(1);
});
it("should handle no collections", async () => {
test("should handle no collections", async () => {
const team = await buildTeam();
const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
@@ -133,7 +131,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(0);
});
it("should handle backslashes in search term", async () => {
test("should handle backslashes in search term", async () => {
const team = await buildTeam();
const { results } = await SearchHelper.searchForTeam(team, {
query: "\\\\",
@@ -141,7 +139,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(0);
});
it("should return the total count of search results", async () => {
test("should return the total count of search results", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
@@ -162,7 +160,7 @@ describe("SearchHelper", () => {
expect(total).toBe(2);
});
it("should return the document when searched with their previous titles", async () => {
test("should return the document when searched with their previous titles", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
@@ -180,7 +178,7 @@ describe("SearchHelper", () => {
expect(total).toBe(1);
});
it("should not return the document when searched with neither the titles nor the previous titles", async () => {
test("should not return the document when searched with neither the titles nor the previous titles", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
@@ -200,12 +198,7 @@ describe("SearchHelper", () => {
});
describe("#searchForUser", () => {
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
it("should return search results from collections", async () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -233,7 +226,7 @@ describe("SearchHelper", () => {
expect(results[0].document?.id).toBe(document.id);
});
it("should return search results for a user without search term", async () => {
test("should return search results for a user without search term", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -261,7 +254,7 @@ describe("SearchHelper", () => {
);
});
it("should return search results from a collection without search term", async () => {
test("should return search results from a collection without search term", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -291,7 +284,7 @@ describe("SearchHelper", () => {
);
});
it("should handle no collections", async () => {
test("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const { results } = await SearchHelper.searchForUser(user, {
@@ -300,7 +293,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(0);
});
it("should search only drafts created by user", async () => {
test("should search only drafts created by user", async () => {
const user = await buildUser();
await buildDraftDocument({
title: "test",
@@ -331,7 +324,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(1);
});
it("should not include drafts with user read permission", async () => {
test("should not include drafts with user read permission", async () => {
const user = await buildUser();
await buildDraftDocument({
title: "test",
@@ -356,7 +349,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(0);
});
it("should search only published created by user", async () => {
test("should search only published created by user", async () => {
const user = await buildUser();
await buildDocument({
title: "test",
@@ -387,7 +380,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(1);
});
it("should search only archived documents created by user", async () => {
test("should search only archived documents created by user", async () => {
const user = await buildUser();
await buildDocument({
title: "test",
@@ -424,7 +417,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(1);
});
it("should return results from archived and published", async () => {
test("should return results from archived and published", async () => {
const user = await buildUser();
await buildDraftDocument({
teamId: user.teamId,
@@ -452,7 +445,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(2);
});
it("should return results from drafts and published", async () => {
test("should return results from drafts and published", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
@@ -480,7 +473,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(2);
});
it("should include results from drafts and archived", async () => {
test("should include results from drafts and archived", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
@@ -508,7 +501,7 @@ describe("SearchHelper", () => {
expect(results.length).toBe(2);
});
it("should return the total count of search results", async () => {
test("should return the total count of search results", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -533,7 +526,7 @@ describe("SearchHelper", () => {
expect(total).toBe(2);
});
it("should return the document when searched with their previous titles", async () => {
test("should return the document when searched with their previous titles", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -554,7 +547,7 @@ describe("SearchHelper", () => {
expect(total).toBe(1);
});
it("should not return the document when searched with neither the titles nor the previous titles", async () => {
test("should not return the document when searched with neither the titles nor the previous titles", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -575,7 +568,7 @@ describe("SearchHelper", () => {
expect(total).toBe(0);
});
it("should find exact phrases", async () => {
test("should find exact phrases", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -596,7 +589,7 @@ describe("SearchHelper", () => {
expect(total).toBe(1);
});
it("should correctly handle removal of trailing spaces", async () => {
test("should correctly handle removal of trailing spaces", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -619,12 +612,7 @@ describe("SearchHelper", () => {
});
describe("#searchTitlesForUser", () => {
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
it("should return search results from collections", async () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -644,7 +632,7 @@ describe("SearchHelper", () => {
expect(documents[0]?.id).toBe(document.id);
});
it("should filter to specific collection", async () => {
test("should filter to specific collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -680,7 +668,7 @@ describe("SearchHelper", () => {
expect(documents[0]?.id).toBe(document.id);
});
it("should handle no collections", async () => {
test("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const documents = await SearchHelper.searchTitlesForUser(user, {
@@ -689,7 +677,7 @@ describe("SearchHelper", () => {
expect(documents.length).toBe(0);
});
it("should search only drafts created by user", async () => {
test("should search only drafts created by user", async () => {
const user = await buildUser();
await buildDraftDocument({
title: "test",
@@ -720,7 +708,7 @@ describe("SearchHelper", () => {
expect(documents.length).toBe(1);
});
it("should search only published created by user", async () => {
test("should search only published created by user", async () => {
const user = await buildUser();
await buildDocument({
title: "test",
@@ -751,7 +739,7 @@ describe("SearchHelper", () => {
expect(documents.length).toBe(1);
});
it("should search only archived documents created by user", async () => {
test("should search only archived documents created by user", async () => {
const user = await buildUser();
await buildDocument({
title: "test",
@@ -788,7 +776,7 @@ describe("SearchHelper", () => {
expect(documents.length).toBe(1);
});
it("should return results from archived and published", async () => {
test("should return results from archived and published", async () => {
const user = await buildUser();
await buildDraftDocument({
teamId: user.teamId,
@@ -816,7 +804,7 @@ describe("SearchHelper", () => {
expect(documents.length).toBe(2);
});
it("should return results from drafts and published", async () => {
test("should return results from drafts and published", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
@@ -844,7 +832,7 @@ describe("SearchHelper", () => {
expect(documents.length).toBe(2);
});
it("should include results from drafts and archived", async () => {
test("should include results from drafts and archived", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
@@ -874,12 +862,7 @@ describe("SearchHelper", () => {
});
describe("#searchCollectionsForUser", () => {
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
it("should return search results from collections", async () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
@@ -901,7 +884,7 @@ describe("SearchHelper", () => {
expect(results[0].id).toBe(collection1.id);
});
it("should return all collections when no query provided", async () => {
test("should return all collections when no query provided", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
@@ -924,196 +907,25 @@ describe("SearchHelper", () => {
});
describe("webSearchQuery", () => {
it("should correctly sanitize query", () => {
test("should correctly sanitize query", () => {
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
expect(SearchHelper.webSearchQuery("one\\two")).toBe("one\\\\two:*");
expect(SearchHelper.webSearchQuery("test''")).toBe("test");
});
it("should wildcard unquoted queries", () => {
test("should wildcard unquoted queries", () => {
expect(SearchHelper.webSearchQuery("test")).toBe("test:*");
expect(SearchHelper.webSearchQuery("'")).toBe("");
expect(SearchHelper.webSearchQuery("'quoted'")).toBe(`"quoted":*`);
});
it("should wildcard multi-word queries", () => {
test("should wildcard multi-word queries", () => {
expect(SearchHelper.webSearchQuery("this is a test")).toBe(
"this&is&a&test:*"
);
});
it("should not wildcard quoted queries", () => {
test("should not wildcard quoted queries", () => {
expect(SearchHelper.webSearchQuery(`"this is a test"`)).toBe(
`"this<->is<->a<->test"`
);
});
});
describe("searchConfig", () => {
it("should boost recent documents when boostRecentMonths is set", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const now = new Date();
const recentDoc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test document recent",
text: "test search term recent",
});
// Set date 4 months ago
const olderDoc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test document older",
text: "test search term older test",
createdAt: subMonths(now, 4),
updatedAt: subMonths(now, 4),
});
// Search without recency boost
const resultsWithoutBoost = await SearchHelper.searchForUser(user, {
query: "test search term",
});
// Search with recency boost
const resultsWithBoost = await SearchHelper.searchForUser(user, {
query: "test search term",
searchConfig: {
boostRecent: true,
boostRecentMonths: 6,
maxRecentBoost: 2.0,
},
});
// Without boost, documents should be ordered by base relevance
expect(resultsWithoutBoost.results.length).toBe(2);
expect(resultsWithoutBoost.results[0].document.id).toBe(olderDoc.id);
expect(resultsWithoutBoost.results[1].document.id).toBe(recentDoc.id);
// With boost, recent document should be ranked higher
expect(resultsWithBoost.results.length).toBe(2);
expect(resultsWithBoost.results[0].document.id).toBe(recentDoc.id);
expect(resultsWithBoost.results[1].document.id).toBe(olderDoc.id);
// Recent document should have higher ranking
expect(resultsWithBoost.results[0].ranking).toBeGreaterThan(
resultsWithBoost.results[1].ranking
);
});
it("should respect different time windows", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const now = new Date();
const recentDoc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test document recent",
text: "test search term recent",
});
// Set date 2 months ago
const twoMonthOldDoc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test document two months",
text: "test search term two months",
createdAt: subMonths(now, 2),
updatedAt: subMonths(now, 2),
});
// Search with 1-month window
const resultsShortWindow = await SearchHelper.searchForUser(user, {
query: "test search term",
searchConfig: {
boostRecent: true,
boostRecentMonths: 1,
maxRecentBoost: 2.0,
},
});
// Search with 3-month window
const resultsLongWindow = await SearchHelper.searchForUser(user, {
query: "test search term",
searchConfig: {
boostRecentMonths: 3,
maxRecentBoost: 2.0,
},
});
// With 1-month window, two-month-old doc should have no boost
expect(resultsShortWindow.results[0].document.id).toBe(recentDoc.id);
expect(resultsShortWindow.results[1].document.id).toBe(twoMonthOldDoc.id);
expect(resultsShortWindow.results[0].ranking).toBeGreaterThan(
resultsShortWindow.results[1].ranking * 1.5
);
// With 3-month window, two-month-old doc should have some boost
expect(resultsLongWindow.results[0].document.id).toBe(recentDoc.id);
expect(resultsLongWindow.results[1].document.id).toBe(twoMonthOldDoc.id);
const rankingRatio =
resultsLongWindow.results[0].ranking /
resultsLongWindow.results[1].ranking;
expect(rankingRatio).toBeLessThan(1.5);
expect(rankingRatio).toBeGreaterThan(1.0);
});
it("should respect custom boost factor", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const now = new Date();
const recentDoc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test document recent",
text: "test search term recent",
});
// Set date 2 months ago
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test document older",
text: "test search term older",
createdAt: subMonths(now, 2),
updatedAt: subMonths(now, 2),
});
// Search with low boost factor
const resultsLowBoost = await SearchHelper.searchForUser(user, {
query: "test search term",
searchConfig: {
boostRecent: true,
boostRecentMonths: 6,
maxRecentBoost: 1.2,
},
});
// Search with high boost factor
const resultsHighBoost = await SearchHelper.searchForUser(user, {
query: "test search term",
searchConfig: {
boostRecent: true,
boostRecentMonths: 6,
maxRecentBoost: 3.0,
},
});
// Both searches should rank recent document higher
expect(resultsLowBoost.results[0].document.id).toBe(recentDoc.id);
expect(resultsHighBoost.results[0].document.id).toBe(recentDoc.id);
// High boost should have greater difference in rankings
const lowBoostRatio =
resultsLowBoost.results[0].ranking / resultsLowBoost.results[1].ranking;
const highBoostRatio =
resultsHighBoost.results[0].ranking /
resultsHighBoost.results[1].ranking;
expect(highBoostRatio).toBeGreaterThan(lowBoostRatio);
});
});
});
+15 -73
View File
@@ -37,14 +37,6 @@ type SearchResponse = {
total: number;
};
type SearchConfig = {
boostRecent?: boolean;
/** Time window in months for recent content boosting. When set, enables recency boosting. */
boostRecentMonths?: number;
/** Maximum boost multiplier for recent content */
maxRecentBoost?: number;
};
type SearchOptions = {
/** The query limit for pagination */
limit?: number;
@@ -68,8 +60,6 @@ type SearchOptions = {
snippetMinWords?: number;
/** The maximum number of words to be returned in the contextual snippet */
snippetMaxWords?: number;
/** Configuration for search behavior */
searchConfig?: SearchConfig;
};
type RankedDocument = Document & {
@@ -89,7 +79,7 @@ export default class SearchHelper {
team: Team,
options: SearchOptions = {}
): Promise<SearchResponse> {
const { limit = 15, offset = 0, query, searchConfig } = options;
const { limit = 15, offset = 0, query } = options;
const where = await this.buildWhere(team, {
...options,
@@ -111,7 +101,7 @@ export default class SearchHelper {
});
}
const findOptions = this.buildFindOptions(query, searchConfig);
const findOptions = this.buildFindOptions(query);
try {
const resultsQuery = Document.unscoped().findAll({
@@ -192,18 +182,9 @@ export default class SearchHelper {
},
];
return Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
return Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
@@ -246,11 +227,11 @@ export default class SearchHelper {
user: User,
options: SearchOptions = {}
): Promise<SearchResponse> {
const { limit = 15, offset = 0, query, searchConfig } = options;
const { limit = 15, offset = 0, query } = options;
const where = await this.buildWhere(user, options);
const findOptions = this.buildFindOptions(query, searchConfig);
const findOptions = this.buildFindOptions(query);
const include = [
{
@@ -283,18 +264,7 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
@@ -319,46 +289,18 @@ export default class SearchHelper {
}
}
private static buildFindOptions(
query?: string,
searchConfig?: SearchConfig
): FindOptions {
private static buildFindOptions(query?: string): FindOptions {
const attributes: FindAttributeOptions = ["id"];
const replacements: BindOrReplacements = {};
const order: Order = [["updatedAt", "DESC"]];
if (query) {
// Default values for recency boosting
const boostRecent = searchConfig?.boostRecent ?? false;
const boostRecentMonths = searchConfig?.boostRecentMonths ?? 2;
const maxRecentBoost = searchConfig?.maxRecentBoost ?? 2.0;
if (boostRecent) {
// Calculate ranking with recency boost
// The formula creates a multiplier between 1.0 and maxRecentBoost based on document age
attributes.push([
Sequelize.literal(
`(
ts_rank("searchVector", to_tsquery('english', :query)) *
(1 + (LEAST(
${maxRecentBoost - 1},
(1 - EXTRACT(EPOCH FROM (NOW() - document."updatedAt")) /
EXTRACT(EPOCH FROM INTERVAL '${boostRecentMonths} months'))
) * ${maxRecentBoost}))
)`
),
"searchRanking",
]);
} else {
// Original ranking without recency boost
attributes.push([
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
]);
}
attributes.push([
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
]);
replacements["query"] = this.webSearchQuery(query);
order.unshift(["searchRanking", "DESC"]);
}
@@ -33,6 +33,8 @@ class OAuthAuthentication extends ParanoidModel<
InferAttributes<OAuthAuthentication>,
Partial<InferCreationAttributes<OAuthAuthentication>>
> {
static eventNamespace = "oauthAuthentications";
/** The lifetime of an access token in seconds. */
public static accessTokenLifetime = env.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME;
@@ -27,6 +27,8 @@ class OAuthAuthorizationCode extends IdModel<
InferAttributes<OAuthAuthorizationCode>,
Partial<InferCreationAttributes<OAuthAuthorizationCode>>
> {
static eventNamespace = "oauthAuthorizationCodes";
/** The lifetime of an authorization code in seconds. */
public static authorizationCodeLifetime =
env.OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME;
+2
View File
@@ -35,6 +35,8 @@ class OAuthClient extends ParanoidModel<
InferAttributes<OAuthClient>,
Partial<InferCreationAttributes<OAuthClient>>
> {
static eventNamespace = "oauthClients";
public static clientSecretPrefix = "ol_sk_";
@NotContainsUrl
+1 -2
View File
@@ -24,14 +24,13 @@ export default function presentOAuthClient(oauthClient: OAuthClient) {
/**
* Important: This function is used to present the OAuth client to users
* that are not in the same workspace as the client. Be very careful about
* that are NOT in the same workspace as the client. Be very careful about
* what you expose here.
*
* @param oauthClient The OAuth client to present
*/
export function presentPublishedOAuthClient(oauthClient: OAuthClient) {
return {
id: oauthClient.id,
name: oauthClient.name,
description: oauthClient.description,
developerName: oauthClient.developerName,
+2 -2
View File
@@ -17,7 +17,7 @@ export default class AvatarProcessor extends BaseProcessor {
});
if (user.avatarUrl) {
await UploadUserAvatarTask.schedule({
await new UploadUserAvatarTask().schedule({
userId: event.userId,
avatarUrl: user.avatarUrl,
});
@@ -30,7 +30,7 @@ export default class AvatarProcessor extends BaseProcessor {
});
if (team.avatarUrl) {
await UploadTeamAvatarTask.schedule({
await new UploadTeamAvatarTask().schedule({
teamId: event.teamId,
avatarUrl: team.avatarUrl,
});
@@ -12,7 +12,7 @@ export default class CollectionsProcessor extends BaseProcessor {
];
async perform(event: CollectionEvent) {
await DetachDraftsFromCollectionTask.schedule({
await new DetachDraftsFromCollectionTask().schedule({
collectionId: event.collectionId,
actorId: event.actorId,
ip: event.ip,
@@ -27,7 +27,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async perform(event: ReceivedEvent) {
switch (event.name) {
case "collections.remove_user": {
await CollectionSubscriptionRemoveUserTask.schedule(event);
await new CollectionSubscriptionRemoveUserTask().schedule(event);
return;
}
@@ -35,7 +35,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
return this.handleRemoveGroupFromCollection(event);
case "documents.remove_user": {
await DocumentSubscriptionRemoveUserTask.schedule(event);
await new DocumentSubscriptionRemoveUserTask().schedule(event);
return;
}
@@ -57,11 +57,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
CollectionSubscriptionRemoveUserTask.schedule({
new CollectionSubscriptionRemoveUserTask().schedule({
...event,
name: "collections.remove_user",
userId: groupUser.userId,
})
} as CollectionUserEvent)
)
);
}
@@ -79,11 +79,11 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
DocumentSubscriptionRemoveUserTask.schedule({
new DocumentSubscriptionRemoveUserTask().schedule({
...event,
name: "documents.remove_user",
userId: groupUser.userId,
})
} as DocumentUserEvent)
)
);
}
@@ -20,12 +20,12 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
if (fileOperation.type === FileOperationType.Import) {
switch (fileOperation.format) {
case FileOperationFormat.MarkdownZip:
await ImportMarkdownZipTask.schedule({
await new ImportMarkdownZipTask().schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await ImportJSONTask.schedule({
await new ImportJSONTask().schedule({
fileOperationId: event.modelId,
});
break;
@@ -36,17 +36,17 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
if (fileOperation.type === FileOperationType.Export) {
switch (fileOperation.format) {
case FileOperationFormat.HTMLZip:
await ExportHTMLZipTask.schedule({
await new ExportHTMLZipTask().schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.MarkdownZip:
await ExportMarkdownZipTask.schedule({
await new ExportMarkdownZipTask().schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await ExportJSONTask.schedule({
await new ExportJSONTask().schedule({
fileOperationId: event.modelId,
});
break;
@@ -20,7 +20,7 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
}
// Store the available issue sources in the integration record.
await CacheIssueSourcesTask.schedule({
await new CacheIssueSourcesTask().schedule({
integrationId: integration.id,
});
@@ -62,25 +62,25 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
await DocumentPublishedNotificationsTask.schedule(event);
await new DocumentPublishedNotificationsTask().schedule(event);
}
async documentAddUser(event: DocumentUserEvent) {
if (!event.data.isNew || event.userId === event.actorId) {
return;
}
await DocumentAddUserNotificationsTask.schedule(event);
await new DocumentAddUserNotificationsTask().schedule(event);
}
async documentAddGroup(event: DocumentGroupEvent) {
if (!event.data.isNew) {
return;
}
await DocumentAddGroupNotificationsTask.schedule(event);
await new DocumentAddGroupNotificationsTask().schedule(event);
}
async revisionCreated(event: RevisionEvent) {
await RevisionCreatedNotificationsTask.schedule(event);
await new RevisionCreatedNotificationsTask().schedule(event);
}
async collectionCreated(event: CollectionEvent) {
@@ -93,7 +93,7 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
await CollectionCreatedNotificationsTask.schedule(event);
await new CollectionCreatedNotificationsTask().schedule(event);
}
async collectionAddUser(event: CollectionUserEvent) {
@@ -101,14 +101,14 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
await CollectionAddUserNotificationsTask.schedule(event);
await new CollectionAddUserNotificationsTask().schedule(event);
}
async commentCreated(event: CommentEvent) {
await CommentCreatedNotificationsTask.schedule(event);
await new CommentCreatedNotificationsTask().schedule(event);
}
async commentUpdated(event: CommentEvent) {
await CommentUpdatedNotificationsTask.schedule(event);
await new CommentUpdatedNotificationsTask().schedule(event);
}
}
@@ -0,0 +1,15 @@
import { OAuthAuthentication } from "@server/models";
import { OAuthClientEvent, Event as TEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class OAuthClientDeletedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["oauthClients.delete"];
async perform(event: OAuthClientEvent) {
await OAuthAuthentication.destroy({
where: {
oauthClientId: event.modelId,
},
});
}
}
@@ -0,0 +1,36 @@
import { Op } from "sequelize";
import { OAuthAuthentication, OAuthClient, User } from "@server/models";
import { OAuthClientEvent, Event as TEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class OAuthClientUnpublishedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["oauthClients.update"];
async perform(event: OAuthClientEvent) {
if (
event.changes?.previous.published === true &&
event.changes.attributes.published === false
) {
const oauthClient = await OAuthClient.findByPk(event.modelId, {
rejectOnEmpty: true,
});
const users = await User.findAll({
attributes: ["id"],
where: {
teamId: oauthClient.teamId,
},
});
const userIds = users.map((user) => user.id);
// Revoke access for all users except any that are in the same team
await OAuthAuthentication.destroy({
where: {
oauthClientId: event.modelId,
userId: {
[Op.notIn]: userIds,
},
},
});
}
}
}
@@ -37,7 +37,7 @@ export default class RevisionsProcessor extends BaseProcessor {
return;
}
await DocumentUpdateTextTask.schedule(event);
await new DocumentUpdateTextTask().schedule(event);
const user = await User.findByPk(event.actorId, {
paranoid: false,
@@ -1,6 +1,7 @@
import {
ApiKey,
GroupUser,
OAuthAuthentication,
Star,
Subscription,
UserAuthentication,
@@ -46,6 +47,12 @@ export default class UserDeletedProcessor extends BaseProcessor {
},
transaction,
});
await OAuthAuthentication.destroy({
where: {
userId: event.userId,
},
transaction,
});
await Star.destroy({
where: {
userId: event.userId,
@@ -6,6 +6,6 @@ export default class UserDemotedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["users.demote"];
async perform(event: UserEvent) {
await CleanupDemotedUserTask.schedule({ userId: event.userId });
await new CleanupDemotedUserTask().schedule({ userId: event.userId });
}
}
+3 -1
View File
@@ -325,7 +325,9 @@ export default abstract class APIImportTask<
([url, attachment]) => ({ attachmentId: attachment.id, url })
);
// publish task after attachments are persisted in DB.
const job = await UploadAttachmentsForImportTask.schedule(uploadItems);
const job = await new UploadAttachmentsForImportTask().schedule(
uploadItems
);
await job.finished();
} catch (err) {
// upload attachments failure is not critical enough to fail the whole import.
+18 -1
View File
@@ -21,7 +21,7 @@ export default abstract class BaseTask<T extends Record<string, any>> {
static cron: TaskSchedule | undefined;
/**
* Schedule this task type to be processed asyncronously by a worker.
* Schedule this task type to be processed asynchronously by a worker.
*
* @param props Properties to be used by the task
* @returns A promise that resolves once the job is placed on the task queue
@@ -39,6 +39,23 @@ export default abstract class BaseTask<T extends Record<string, any>> {
);
}
/**
* Schedule this task type to be processed asynchronously by a worker.
*
* @param props Properties to be used by the task
* @param options Job options such as priority and retry strategy, as defined by Bull.
* @returns A promise that resolves once the job is placed on the task queue
*/
public schedule(props: T, options?: JobOptions): Promise<Job> {
return taskQueue.add(
{
name: this.constructor.name,
props,
},
{ ...options, ...this.options }
);
}
/**
* Execute the task.
*
@@ -29,7 +29,7 @@ export default class CleanupDeletedTeamsTask extends BaseTask<Props> {
});
for (const team of teams) {
await CleanupDeletedTeamTask.schedule({
await new CleanupDeletedTeamTask().schedule({
teamId: team.id,
});
}
@@ -7,7 +7,7 @@ import BaseTask from "./BaseTask";
type Props = {
collectionId: string;
actorId: string;
ip: string;
ip: string | null;
};
export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
@@ -1,6 +1,6 @@
import { Op } from "sequelize";
import { GroupUser } from "@server/models";
import { DocumentGroupEvent } from "@server/types";
import { DocumentGroupEvent, DocumentUserEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
@@ -19,11 +19,12 @@ export default class DocumentAddGroupNotificationsTask extends BaseTask<Document
async (groupUsers) => {
await Promise.all(
groupUsers.map(async (groupUser) => {
await DocumentAddUserNotificationsTask.schedule({
await new DocumentAddUserNotificationsTask().schedule({
...event,
name: "documents.add_user",
modelId: event.data.membershipId,
userId: groupUser.userId,
});
} as DocumentUserEvent);
})
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = {
sourceMetadata: Pick<Required<SourceMetadata>, "fileName" | "mimeType">;
publish?: boolean;
collectionId?: string;
parentDocumentId?: string;
parentDocumentId?: string | null;
ip: string;
key: string;
};
@@ -171,7 +171,8 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
/**
* Generates a map of document urls to their path in the zip file.
*
* @param collections
* @param collections The collections to generate the path map for.
* @param format The format of the exported documents.
*/
private createPathMap(
collections: Collection[],
+7 -5
View File
@@ -44,11 +44,13 @@ export default abstract class ExportTask extends BaseTask<Props> {
? [fileOperation.collectionId]
: await user.collectionIds();
const collections = await Collection.findAll({
where: {
id: collectionIds,
},
});
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where: {
id: collectionIds,
},
}
);
let filePath: string | undefined;
@@ -38,7 +38,7 @@ export default class UpdateTeamsAttachmentsSizeTask extends BaseTask<Props> {
const teamIds = rows.map((row) => row.teamId);
for (const teamId of teamIds) {
await UpdateTeamAttachmentsSizeTask.schedule({ teamId });
await new UpdateTeamAttachmentsSizeTask().schedule({ teamId });
}
}
);
+1 -1
View File
@@ -166,7 +166,7 @@ router.post(
)
);
const job = await UploadAttachmentFromUrlTask.schedule({
const job = await new UploadAttachmentFromUrlTask().schedule({
attachmentId: attachment.id,
url,
});
+1 -1
View File
@@ -135,7 +135,7 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
// If the user did not _just_ sign in then we need to check if they continue
// to have access to the workspace they are signed into.
if (user.lastSignedInAt && user.lastSignedInAt < subHours(new Date(), 1)) {
await ValidateSSOAccessTask.schedule({ userId: user.id });
await new ValidateSSOAccessTask().schedule({ userId: user.id });
}
ctx.body = {
+5 -3
View File
@@ -140,9 +140,11 @@ router.post(
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id);
authorize(user, "readDocument", collection);
+6 -3
View File
@@ -29,7 +29,8 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
for (const name in tasks) {
const TaskClass = tasks[name];
if (TaskClass.cron === period) {
await TaskClass.schedule({ limit });
// @ts-expect-error We won't instantiate an abstract class
await new TaskClass().schedule({ limit });
// Backwards compatibility for installations that have not set up
// cron jobs periods other than daily.
@@ -38,13 +39,15 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
!receivedPeriods.has(TaskSchedule.Minute) &&
(period === TaskSchedule.Hour || period === TaskSchedule.Day)
) {
await TaskClass.schedule({ limit });
// @ts-expect-error We won't instantiate an abstract class
await new TaskClass().schedule({ limit });
} else if (
TaskClass.cron === TaskSchedule.Hour &&
!receivedPeriods.has(TaskSchedule.Hour) &&
period === TaskSchedule.Day
) {
await TaskClass.schedule({ limit });
// @ts-expect-error We won't instantiate an abstract class
await new TaskClass().schedule({ limit });
}
}
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collection: document.collectionId,
collectionId: document.collectionId,
},
});
const body = await res.json();
@@ -1013,7 +1013,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
},
});
const body = await res.json();
+17 -23
View File
@@ -133,15 +133,19 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId: [collectionId] });
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.scope([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = (collection?.documentStructure || [])
documentIds = (collection.documentStructure || [])
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where[Op.and].push({ id: documentIds });
@@ -268,7 +272,7 @@ router.post(
}
const [documents, total] = await Promise.all([
Document.defaultScopeWithUser(user.id).findAll({
Document.withMembershipScope(user.id).findAll({
where,
order: [
[
@@ -348,7 +352,7 @@ router.post(
};
}
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id).findAll({
where,
order: [
[
@@ -397,15 +401,11 @@ router.post(
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", user.id],
};
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([
membershipScope,
collectionScope,
viewScope,
"withDrafts",
]).findAll({
@@ -539,7 +539,9 @@ router.post(
delete where.updatedAt;
}
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
@@ -1060,9 +1062,6 @@ router.post(
limit,
snippetMinWords,
snippetMaxWords,
searchConfig: {
boostRecent: true,
},
});
}
@@ -1542,7 +1541,7 @@ router.post(
acl,
});
const job = await DocumentImportTask.schedule({
const job = await new DocumentImportTask().schedule({
key,
sourceMetadata: {
fileName,
@@ -1552,6 +1551,7 @@ router.post(
collectionId,
parentDocumentId,
publish,
ip: ctx.request.ip,
});
const response: DocumentImportTaskResponse = await job.finished();
if ("error" in response) {
@@ -2035,13 +2035,7 @@ router.post(
const collectionIds = await user.collectionIds({
paranoid: false,
});
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
collectionScope,
"withDrafts",
]).findAll({
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id"],
where: {
deletedAt: {
@@ -2065,7 +2059,7 @@ router.post(
});
if (documents.length) {
await EmptyTrashTask.schedule({
await new EmptyTrashTask().schedule({
documentIds: documents.map((doc) => doc.id),
});
}
@@ -24,6 +24,7 @@ router.post(
async (ctx: APIContext<T.GroupMembershipsListReq>) => {
const { groupId } = ctx.input.body;
const { user } = ctx.state.auth;
const userId = user.id;
const memberships = await GroupMembership.findAll({
where: {
@@ -44,7 +45,7 @@ router.post(
association: "groupUsers",
required: true,
where: {
userId: user.id,
userId,
},
},
],
@@ -57,11 +58,9 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.scope([
"withDrafts",
{ method: ["withMembership", user.id] },
{ method: ["withCollectionPermissions", user.id] },
]).findAll({
const documents = await Document.withMembershipScope(userId, {
includeDrafts: true,
}).findAll({
where: {
id: documentIds,
},
@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`oauthAuthentications.delete should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`oauthAuthentications.list should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
@@ -0,0 +1,194 @@
import { OAuthClient, OAuthAuthentication } from "@server/models";
import {
buildOAuthAuthentication,
buildTeam,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("oauthAuthentications.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthAuthentications.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return list of oauth authentications for user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["read"],
});
const res = await server.post("/api/oauthAuthentications.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toBeDefined();
expect(body.data[0].oauthClient.name).toEqual("Test Client");
expect(body.policies).toBeDefined();
});
it("should only return authentications for requesting user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const anotherUser = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user: anotherUser,
scope: ["read"],
});
const res = await server.post("/api/oauthAuthentications.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
});
describe("oauthAuthentications.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/oauthAuthentications.delete");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should delete all authentications for a client without scope", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["read"],
});
const res = await server.post("/api/oauthAuthentications.delete", {
body: {
token: user.getJwtToken(),
oauthClientId: oauthClient.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
const auths = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId: oauthClient.id,
},
});
expect(auths.length).toEqual(0);
});
it("should delete matching authentications for a client with scope", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["read"],
});
await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user,
scope: ["write"],
});
const res = await server.post("/api/oauthAuthentications.delete", {
body: {
token: user.getJwtToken(),
oauthClientId: oauthClient.id,
scope: ["read"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
const auths = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId: oauthClient.id,
},
});
expect(auths.length).toEqual(1);
expect(auths[0].scope[0]).toEqual("write");
});
it("should only delete authentications for requesting user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const anotherUser = await buildUser({ teamId: team.id });
const oauthClient = await OAuthClient.create({
teamId: team.id,
createdById: user.id,
name: "Test Client",
redirectUris: ["https://example.com/callback"],
});
const otherAuth = await buildOAuthAuthentication({
oauthClientId: oauthClient.id,
user: anotherUser,
scope: ["read"],
});
await server.post("/api/oauthAuthentications.delete", {
body: {
token: user.getJwtToken(),
oauthClientId: oauthClient.id,
scope: "read",
},
});
// Verify other user's auth still exists
const auth = await OAuthAuthentication.findByPk(otherAuth.id);
expect(auth).not.toBeNull();
});
});
@@ -30,6 +30,7 @@ router.post(
oa.*,
oc.id AS "oauthClient.id",
oc.name AS "oauthClient.name",
oc."avatarUrl" AS "oauthClient.avatarUrl",
oc."clientId" AS "oauthClient.clientId"
FROM oauth_authentications oa
INNER JOIN oauth_clients oc ON oc.id = oa."oauthClientId"
@@ -65,12 +66,12 @@ router.post(
transaction(),
async (ctx: APIContext<T.OAuthAuthenticationsDeleteReq>) => {
const { user } = ctx.state.auth;
const { oauthClientId, scope } = ctx.request.body;
const { oauthClientId, scope } = ctx.input.body;
const oauthAuthentications = await OAuthAuthentication.findAll({
where: {
userId: user.id,
oauthClientId,
scope,
...(scope ? { scope } : {}),
},
transaction: ctx.state.transaction,
});
@@ -148,6 +148,46 @@ describe("oauthClients.info", () => {
expect(body.data.id).toBeUndefined();
expect(body.data.redirectUris).toBeUndefined();
});
it("should validate redirectUri parameter", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser();
const client = await OAuthClient.create({
teamId: team.id,
createdById: admin.id,
name: "Test Client",
redirectUris: [
"https://example.com/callback",
"https://another.com/callback",
],
published: true,
});
// Test with valid redirectUri
const validRes = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
clientId: client.clientId,
redirectUri: "https://example.com/callback",
},
});
const validBody = await validRes.json();
expect(validRes.status).toEqual(200);
expect(validBody.data.name).toEqual("Test Client");
// Test with invalid redirectUri
const invalidRes = await server.post("/api/oauthClients.info", {
body: {
token: user.getJwtToken(),
clientId: client.clientId,
redirectUri: "https://malicious.com/callback",
},
});
expect(invalidRes.status).toEqual(400);
});
});
describe("oauthClients.create", () => {
@@ -1,5 +1,6 @@
import Router from "koa-router";
import { UserRole } from "@shared/types";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
@@ -49,7 +50,7 @@ router.post(
auth(),
validate(T.OAuthClientsInfoSchema),
async (ctx: APIContext<T.OAuthClientsInfoReq>) => {
const { id, clientId } = ctx.input.body;
const { id, clientId, redirectUri } = ctx.input.body;
const { user } = ctx.state.auth;
const oauthClient = await OAuthClient.findOne({
@@ -58,6 +59,10 @@ router.post(
});
authorize(user, "read", oauthClient);
if (redirectUri && !oauthClient.redirectUris.includes(redirectUri)) {
throw ValidationError("redirect_uri is invalid");
}
const isInternalApp = oauthClient.teamId === user.teamId;
ctx.body = {
+2
View File
@@ -10,6 +10,8 @@ export const OAuthClientsInfoSchema = BaseSchema.extend({
/** OAuth clientId */
clientId: z.string().optional(),
redirectUri: z.string().optional(),
})
.refine((data) => data.id || data.clientId, {
message: "Either id or clientId is required",
+1 -1
View File
@@ -113,7 +113,7 @@ router.post(
user.collectionIds(),
]);
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id).findAll({
where: {
id: pins.map((pin) => pin.documentId),
collectionId: collectionIds,
+5 -1
View File
@@ -54,7 +54,11 @@ router.post(
});
authorize(user, "read", document);
const collection = await document.$get("collection");
const collection = document.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
document.collectionId
)
: undefined;
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({

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