Compare commits

..

63 Commits

Author SHA1 Message Date
Codegen Bot 0720a65de3 Update yarn.lock for React 18 dependencies 2025-05-20 03:33:22 +00:00
Codegen Bot 95d1f52c2e Update React and React DOM to version 18 and update related dependencies 2025-05-20 03:30:40 +00:00
Codegen Bot a52d9232a9 Update React types to match React 18 version and update testing library 2025-05-20 03:25:53 +00:00
Codegen Bot fe1672356a Update React types to match React 18 version and update testing library 2025-05-20 03:22:18 +00:00
codegen-sh[bot] 2a8d845cbc Update tsconfig.json for React 18 JSX transform 2025-05-20 03:09:58 +00:00
codegen-sh[bot] b593930338 Update ReactDOM.render to createRoot for React 18 2025-05-20 03:09:35 +00:00
codegen-sh[bot] 050200af36 Update React to version 18 2025-05-20 03:04:02 +00:00
codegen-sh[bot] 5274b99277 fix: Update handleDisclosureClick to handle undefined events (#9254)
* fix: Pass synthetic event to onDisclosureClick to prevent TypeError

* fix: Update handleDisclosureClick to handle undefined events

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-19 22:34:01 -04:00
dependabot[bot] 7cabefaf34 chore(deps): bump the aws group with 5 updates (#9250)
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.806.0` | `3.812.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.806.0` | `3.812.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.806.0` | `3.812.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.806.0` | `3.812.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.806.0` | `3.812.0` |


Updates `@aws-sdk/client-s3` from 3.806.0 to 3.812.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.812.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.806.0 to 3.812.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.812.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.806.0 to 3.812.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.812.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.806.0 to 3.812.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.812.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.806.0 to 3.812.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.812.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.812.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.812.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-19 19:31:32 -04:00
dependabot[bot] 81729ae72b chore(deps-dev): bump eslint-import-resolver-typescript (#9252)
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.8.0 to 3.10.1.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/v3.10.1/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.0...v3.10.1)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  dependency-version: 3.10.1
  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-19 17:56:34 -04:00
dependabot[bot] cd2d9fc218 chore(deps): bump @tanstack/react-table from 8.20.6 to 8.21.3 (#9249)
Bumps [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) from 8.20.6 to 8.21.3.
- [Release notes](https://github.com/TanStack/table/releases)
- [Commits](https://github.com/TanStack/table/commits/v8.21.3/packages/react-table)

---
updated-dependencies:
- dependency-name: "@tanstack/react-table"
  dependency-version: 8.21.3
  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-05-19 17:56:18 -04:00
dependabot[bot] 4d7340d70b chore(deps): bump @radix-ui/react-visually-hidden from 1.2.0 to 1.2.2 (#9248)
Bumps [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives) from 1.2.0 to 1.2.2.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.2
  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-19 17:56:11 -04:00
Tom Moor e596b57cc2 chore: Add additional details to validation errors (#9243)
This will help us get to the bottom of Notion importer failures
2025-05-18 20:27:47 -04:00
Translate-O-Tron 58b6901b7b New Crowdin updates (#9046)
* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Dutch translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New Czech translations from Crowdin [ci skip]

* fix: New Danish translations from Crowdin [ci skip]

* fix: New Hebrew translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Polish translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Swedish translations from Crowdin [ci skip]

* fix: New Turkish translations from Crowdin [ci skip]

* fix: New Ukrainian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Traditional translations from Crowdin [ci skip]

* fix: New Vietnamese translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Indonesian translations from Crowdin [ci skip]

* fix: New Persian translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Romanian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* fix: New Hungarian translations from Crowdin [ci skip]

* touch

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-18 23:27:23 +00:00
Tom Moor b8fd239f2e Add additional logging to getFileStream failures (#9242) 2025-05-18 19:24:50 -04:00
Tom Moor 201fbb56eb perf: Add cache for document structure (#9196)
* Normalize Collection.findByPk

* Add caching of documentStructure

* fix: Do not set cache before transaction is flushed

* Mock Redis
2025-05-18 18:45:00 -04:00
Tom Moor 823b0442a2 fix: Image caption uncentered (#9239) 2025-05-18 18:51:50 +00:00
Tom Moor 4ff663e112 Adds menu option to apply template to existing document (#9236)
* Adds menu option to apply template to existing document

* Memoize, docs

* docs
2025-05-18 09:40:37 -04:00
codegen-sh[bot] e5ded0a6a5 Fix misalignment between email and comment mentions (#9234)
* Fix misalignment between email and comment mentions

* Add test

* Update ProsemirrorHelper.tsx

* Optimize user mention processing with batch loading

* Add test for multiple mentions

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-17 12:45:45 -04:00
Balázs Úr 784d075233 feat: Add Hungarian translations (#9230) 2025-05-17 12:22:03 -04:00
codegen-sh[bot] 1c9b300e25 feat: add lint:changed script to lint only unstaged files (#9235)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-17 12:21:15 -04:00
codegen-sh[bot] 870bf1157b fix: Increase defaultSignedUrlExpires from 60s to 5 minutes (#9233)
Closes #8921

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-17 13:07:01 +00:00
codegen-sh[bot] d2aba1de96 feat: Add POST method option to redirectOnClient (#9228)
* feat: Add POST method option to redirectOnClient helper

* Applied automatic fixes

* fix: Add missing closing HTML tag in redirectOnClient GET method

* fix: Use lodash escape for form field values to prevent XSS

* Applied automatic fixes

* fix: Add missing lodash/escape import

* Applied automatic fixes

* fix: Escape all URLs in redirectOnClient function

* Update index.ts

* fix: CSP

* Refactor CSP middleware

* docs, only use for email signin

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-17 09:06:32 -04:00
Hemachandar 052924d816 Resolve index collision when restoring collection (#9229)
* Resolve index collision when restoring collection

* use beforeUpdate hook
2025-05-16 23:56:46 -04:00
Apoorv Mishra 2fe887ef57 Refactor Placeholder plugin to make it configurable (#9224)
* fix: refactor to make configurable

* fix: make it a reusable plugin so that it can be colocated with nodes eligible for placeholders

* fix: cond -> condition

* fix: `pos` -> `$start` as param

* fix: cleanup
2025-05-16 20:58:06 -04:00
Tom Moor e288a5d38e fix: Remove # from stored filesystem keys (#9231) 2025-05-17 00:14:42 +00:00
Hemachandar dc5c3f5280 fix: Consider non-archived collections only for index computation (#9225) 2025-05-16 09:39:32 -04:00
Hemachandar 610721eed6 fix: Ensure collection withUser scope is not overriden (#9226) 2025-05-16 08:16:25 -04:00
Hemachandar d50f0986bb fix: Reset editing state when collection/document title is unmodified (#9221) 2025-05-15 14:12:26 +00:00
Tom Moor 90af35d4bd Update .env.sample (#9217) 2025-05-15 02:08:15 +00:00
Tom Moor 3810373195 fix: CleanupDeletedDocumentsTask attribute selection (#9215) 2025-05-14 20:45:29 -04:00
Tom Moor 3fd893e728 Add new markdown shortcut for tables to shortcut UI (#9216) 2025-05-14 23:52:32 +00:00
Tom Moor 13e3aaf861 chore: Add Sentry breadcrumbs to websocket messages for debugging (#9211) 2025-05-14 19:37:29 -04:00
Tom Moor b43ebabbaf fix: Apex redirect regression (#9214) 2025-05-14 19:37:16 -04:00
Tom Moor 42550a003a fix: Icon on collection home does not match sidebar when private (#9209) 2025-05-14 04:23:55 +00:00
Tom Moor 08b7c11461 Normalize Collection.findByPk (#9193) 2025-05-14 00:05:45 -04:00
Tom Moor 8a9a8cf751 fix: Archived documents should not show in @mention suggestions (#9208) 2025-05-13 22:47:58 -04:00
Tom Moor 2d6167e933 fix: Add encoding meta tag to exported HTML (#9207)
closes #9194
2025-05-13 22:47:48 -04:00
dependabot[bot] 6b05b101d0 chore(deps): bump the aws group with 5 updates (#9186)
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.803.0` | `3.806.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.803.0` | `3.806.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.803.0` | `3.806.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.803.0` | `3.806.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.803.0` | `3.806.0` |


Updates `@aws-sdk/client-s3` from 3.803.0 to 3.806.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.806.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.803.0 to 3.806.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.806.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.803.0 to 3.806.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.806.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.803.0 to 3.806.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.806.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.803.0 to 3.806.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.806.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-version: 3.806.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-version: 3.806.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-13 21:06:24 -04:00
Adam Roe 79fe73fbe1 fix: fall back to id_token when OIDC userinfo endpoint is sparse (#9172)
* Fall back to id_token if profile does not contain username or email

* More comments

* Add error handling to id_token decode

* simplify username fallback logic using nullish coalescing

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* make id_token decoding more tolerant of malformed or invalid tokens

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-05-13 21:06:14 -04:00
MehdiBouzouaya 63376ed9c8 Fix flash of previous value after editing title in sidebar for documents (#9197)
* Fix flash of previous value after editing title in sidebar for documents

* Fix flash of previous value after editing title in sidebar for documents
2025-05-13 22:36:47 +00:00
MehdiBouzouaya 0cec66b3bb Fix: Add missing key prop to context menu items to avoid React warning (#9202) 2025-05-13 18:31:21 -04:00
dependabot[bot] fcc73e772b chore(deps): bump the babel group with 2 updates (#9185)
Bumps the babel group with 2 updates: [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) and [@babel/cli](https://github.com/babel/babel/tree/HEAD/packages/babel-cli).


Updates `@babel/preset-env` from 7.27.1 to 7.27.2
- [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.2/packages/babel-preset-env)

Updates `@babel/cli` from 7.27.1 to 7.27.2
- [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.2/packages/babel-cli)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.27.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/cli"
  dependency-version: 7.27.2
  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-12 19:29:38 -04:00
dependabot[bot] b5cb6128c4 chore(deps-dev): bump react-refresh from 0.14.2 to 0.17.0 (#9187)
Bumps [react-refresh](https://github.com/facebook/react/tree/HEAD/packages/react) from 0.14.2 to 0.17.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react)

---
updated-dependencies:
- dependency-name: react-refresh
  dependency-version: 0.17.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-12 19:29:22 -04:00
dependabot[bot] 261226c110 chore(deps): bump @types/mailparser from 3.4.5 to 3.4.6 (#9188)
Bumps [@types/mailparser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mailparser) from 3.4.5 to 3.4.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mailparser)

---
updated-dependencies:
- dependency-name: "@types/mailparser"
  dependency-version: 3.4.6
  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-12 19:05:25 -04:00
Tom Moor 6fff437196 fix: Incorrect translation string on disabled API keys (#9183) 2025-05-12 07:56:46 -04:00
Tom Moor 4f34e70d32 chore: Add automation to close PRs that never accept CLA (#9179)
* Add script to auto-close stale PRs

* Improve specificity
2025-05-11 17:14:55 -04:00
Tom Moor 4c04bd9359 v0.84.0 (#9176) 2025-05-11 15:58:16 +00:00
Tom Moor 16c8ae6132 Create README.md (#9174) 2025-05-11 12:56:26 +00:00
Tom Moor 30bba3a69b fix: JS error when no integrations are connected (#9170) 2025-05-11 02:05:18 +00:00
Tom Moor 32c1712fdc fix: Various cases that could leave file handles open on export (#9168)
* fix: Various cases that could leave file handles open on export

* Consolidate error handling
2025-05-10 17:48:24 -04:00
Tom Moor d392149860 fix: Non-integration plugins missing in settings (#9167)
Other minor refactors
2025-05-10 12:45:06 -04:00
Tom Moor 30108ebded chore: Move Zapier settings page to plugin (#9166) 2025-05-10 10:25:46 -04:00
Tom Moor d0bd2baa9f Add integrations page (#9155)
* update useSettings

* Integration page skeleton

* add descriptions

* update design

* Integration page style update

* clean up

* update integration card

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update integration icon size

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update all integrations menu item

* update IntegrationCard to use the `Text` component

* update card status

* fix: Google analytics never shows as installed
fix: Styling tweaks
Move webhooks out of integrations

* Add breadcrumbs

* Add filtering

* refactor

* Add hover state, tweak descriptions

---------

Co-authored-by: Tess99854 <tesnimesb@gmail.com>
Co-authored-by: Mahmoud Mohammed Ali <ibn.el4ai5@gmail.com>
Co-authored-by: Mahmoud Ali <mahmoud.ali.khallaf@gmail.com>
2025-05-10 09:59:41 -04:00
Tom Moor fd984774d0 Add smart preloading of settings screens to reduce flicker (#9165) 2025-05-10 09:17:43 -04:00
Tom Moor e216c68f6d fix: CMD+F with in-app find interface open should open native find interface (#9153) 2025-05-08 21:40:01 -04:00
Tom Moor 2e2a8bcc94 fix: Allow searching for current user in collection permissions (#9154) 2025-05-08 22:15:16 +00:00
Tom Moor 245d14f905 fix: Upgrade KaTeX (#9151) 2025-05-08 00:40:50 +00:00
Tom Moor 8717d160ce fix: Backlinks are limited at 25 (#9150) 2025-05-07 20:36:56 -04:00
Tom Moor 587ba85cc9 fix: LaTeX blocks show vertical scrollbar (#9149) 2025-05-08 00:17:47 +00:00
Tom Moor 80bb1ce977 fix: ExportDocumentTreeTask needs documentStructure (#9148) 2025-05-07 23:42:59 +00: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
135 changed files with 5361 additions and 2067 deletions
+165 -140
View File
@@ -1,48 +1,80 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# should work out of the box.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://redis:6379
# or alternatively, if you would like to provide additional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
# The port to expose the Outline server on, this should match what is configured
# in your docker-compose.yml
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set.
COLLABORATION_URL=
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Generate a hex-encoded 32-byte random key. Use `openssl rand -hex 32` in your
# terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to generate a random value.
UTILS_SECRET=generate_a_new_key
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DATABASE –––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The database URL for your production database, including username, password, and database name.
DATABASE_URL=postgres://user:pass@postgres:5432/outline
# The in-memory database pool per-process settings. Ensure that the pool size that will not exceed
# the maximum number of connections allowed by your database. Defaults to 0 and 5.
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this line if you will not use SSL for connecting to Postgres. This is acceptable
# if the database and the application are on the same machine.
# PGSSLMODE=disable
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– REDIS –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The Redis URL for your environment you can either specify an ioredis compatible url or a Base64
# encoded configuration object.
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
REDIS_URL=redis://redis:6379
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE STORAGE –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Specify what storage system to use. Possible value is one of "s3" or "local".
# For "local", the avatar images and document attachments will be saved on local disk.
# For "local" images and document attachments will be saved on local disk, for "s3" they
# will be stored in an S3-compatible network store.
# DOCS: https://docs.getoutline.com/s/hosting/doc/file-storage-N4M0T6Ypu7
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images go. Make sure that the process has permissions to create
# this path and also to write files to it.
# which all attachments/images are stored. Make sure that the process has permissions to
# create this path and also to write files to it.
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
@@ -56,8 +88,8 @@ FILE_STORAGE_IMPORT_MAX_SIZE=
# and the files are temporary being automatically deleted after a period of time.
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
# To support uploading of images for avatars and document attachments in a distributed
# architecture an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
# To support uploading of images for avatars and document attachments in a distributed
# architecture, an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
@@ -67,38 +99,55 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# –––––––––––––– AUTHENTICATION ––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––––– SSL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is one
# of three ways to configure SSL and can be left empty.
# DOCS: https://docs.getoutline.com/s/hosting/doc/ssl-pzk7WO8d1n
SSL_KEY=
SSL_CERT=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# or Microsoft is required for a working installation or you'll have no sign-in
# options.
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# To configure Slack auth, you'll need to create an Application at
# => https://api.slack.com/apps
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
#
# When configuring the Client ID, add an Authorized redirect URI:
# https://<URL>/auth/google.callback
# Google sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
# Microsoft Entra / Azure AD sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
# Discord sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/discord-g4JdWFFub6
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_SERVER_ID=
DISCORD_SERVER_ROLES=
# Generic OIDC provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/oidc-8CPBm6uC0I
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
@@ -116,83 +165,54 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# To configure the GitHub integration, you'll need to create a GitHub App at
# => https://github.com/settings/apps
#
# When configuring the Client ID, add a redirect URL under "Permissions & events":
# https://<URL>/api/github.callback
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– EMAIL –––––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# To support sending outgoing transactional emails such as "document updated" or
# email sign-in you'll need to connect an SMTP server. Service can be configured
# with any service from this list: https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
# DOCS: https://docs.getoutline.com/s/hosting/doc/smtp-cqCJyZGMIB
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– RATE LIMITER ––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Whether the rate limiter is enabled or not
RATE_LIMITER_ENABLED=true
# Individual endpoints have hardcoded rate limits that are enabled
# with the above setting, however this is a global rate limiter
# across all requests
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# The GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Linear
# The Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/auth/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
# integrated with.
# Used to verify that the user is a member of the server as well as server
# metadata such as nicknames, server icon and name.
DISCORD_SERVER_ID=
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
# allowed to access Outline. If this is not set, all members of the server
# will be allowed to access Outline.
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
# required if you do not use an external reverse proxy. See documentation:
# https://wiki.generaloutline.com/share/1c922644-40d8-41fe-98f9-df2b67239d45
SSL_KEY=
SSL_CERT=
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug and silly
LOG_LEVEL=info
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
@@ -202,29 +222,34 @@ SLACK_MESSAGE_ACTIONS=true
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
SENTRY_DSN=
SENTRY_TUNNEL=
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_SERVICE=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
# Enable importing pages from a Notion workspace
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Iframely API config
# The Iframely integration allows previews of third-party content within Outline.
# For example, hovering over an external link will show a preview.
# DOCS: https://docs.getoutline.com/s/hosting/doc/iframely-HwLF1EZ9mo
IFRAMELY_URL=
IFRAMELY_API_KEY=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––––– DEBUGGING ––––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Have the installation check for updates by sending anonymized statistics to
# the maintainers
ENABLE_UPDATES=true
# Debugging categories to enable you can remove the default "http" value if
# your proxy already logs incoming http requests and this ends up being duplicative
DEBUG=http
# Configure lowest severity level for server logs. Should be one of
# error, warn, info, http, verbose, debug, or silly
LOG_LEVEL=info
+59
View File
@@ -0,0 +1,59 @@
name: Auto Close Unsigned PRs
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
jobs:
close-unsigned-prs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Close unsigned PRs
uses: actions/github-script@v6
with:
script: |
const now = new Date();
const TWO_WEEKS = 14 * 24 * 60 * 60 * 1000; // 14 days in milliseconds
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
for (const pr of prs.data) {
const prCreatedAt = new Date(pr.created_at);
const prAge = now - prCreatedAt;
if (prAge < TWO_WEEKS) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const hasNotSignedComment = comments.data.some(comment =>
comment.body.toLowerCase().includes('https://cla-assistant.io/pull/badge/not_signed')
);
if (hasNotSignedComment) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'This PR has been automatically closed because it has been open for more than 14 days and has not accepted the CLA.'
});
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
Licensed Work: Outline 0.84.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-04-11
Change Date: 2029-05-11
Change License: Apache License, Version 2.0
+8 -2
View File
@@ -15,6 +15,7 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
@@ -22,7 +23,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -88,6 +89,11 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
React.useEffect(() => {
void IconPicker.preload();
}, []);
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
@@ -202,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker)`
const StyledIconPicker = styled(IconPicker.Component)`
margin-left: 4px;
margin-right: 4px;
`;
+19 -10
View File
@@ -138,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={index}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
@@ -154,7 +154,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
<MenuItem
id={`${item.title}-${index}`}
href={item.href}
key={index}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
@@ -176,7 +176,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={index}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
@@ -185,18 +185,25 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
return (
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={index}
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
@@ -209,15 +216,17 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
{...menu}
/>
);
) : null;
}
if (item.type === "separator") {
return <Separator key={index} />;
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return <Header key={index}>{item.title}</Header>;
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
const _exhaustiveCheck: never = item;
+4 -1
View File
@@ -64,11 +64,12 @@ function EditableTitle(
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
setIsEditing(false);
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
setValue(originalValue);
setIsEditing(false);
onCancel?.();
return;
}
@@ -80,6 +81,8 @@ function EditableTitle(
setValue(originalValue);
toast.error(error.message);
throw error;
} finally {
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit]
+5 -1
View File
@@ -45,6 +45,7 @@ type Props = {
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
children?: React.ReactNode;
};
const IconPicker = ({
@@ -59,6 +60,7 @@ const IconPicker = ({
onOpen,
onClose,
borderOnHover,
children,
}: Props) => {
const { t } = useTranslation();
@@ -174,7 +176,9 @@ const IconPicker = ({
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{iconType && icon ? (
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
interface LazyLoadOptions {
retries?: number;
interval?: number;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = React.lazy(
const EmojiPanel = createLazyComponent(
() => import("~/components/IconPicker/components/EmojiPanel")
);
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel
<EmojiPanel.Component
height={300}
panelWidth={panelWidth}
query={query}
@@ -93,11 +93,13 @@ export const Suggestions = observer(
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = (
document
? users.notInDocument(document.id, query)
? users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
).filter((u) => !u.isSuspended && u.id !== user.id);
).filter((u) => !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
+12 -3
View File
@@ -23,12 +23,20 @@ import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
function SettingsSidebar() {
const { ui } = useStores();
const { ui, integrations } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
"group"
);
const returnToApp = React.useCallback(() => {
history.push("/home");
@@ -63,8 +71,9 @@ function SettingsSidebar() {
<SidebarLink
key={item.path}
to={item.path}
onClickIntent={item.preload}
active={
item.path !== settingsPath()
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
@@ -22,7 +22,7 @@ import SidebarContext from "./SidebarContext";
function Collections() {
const { documents, collections } = useStores();
const { t } = useTranslation();
const orderedCollections = collections.orderedData;
const orderedCollections = collections.allActive;
const params = React.useMemo(
() => ({
@@ -78,9 +78,9 @@ function StarredLink({ star }: Props) {
}, [documentId, documents]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
(ev?: React.MouseEvent<HTMLButtonElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
},
[]
+14 -2
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/react";
import invariant from "invariant";
import find from "lodash/find";
import { action, observable } from "mobx";
@@ -134,6 +135,15 @@ class WebsocketProvider extends React.Component<Props> {
throw err;
});
// add a listener for all events that logs a sentry breadcrumb
this.socket.onAny((event: string, data: Record<string, unknown>) => {
Sentry.addBreadcrumb({
category: "websocket",
message: `Received event: ${event}`,
data,
});
});
this.socket.on(
"entities",
action(async (event: WebsocketEntitiesEvent) => {
@@ -251,8 +261,10 @@ class WebsocketProvider extends React.Component<Props> {
}
policies.remove(document.id);
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
}
)
);
+1
View File
@@ -41,6 +41,7 @@ function useKeyboardShortcuts({
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
+35 -25
View File
@@ -1,10 +1,12 @@
import { action } from "mobx";
import { PlusIcon } from "outline-icons";
import { Plugin } from "prosemirror-state";
import { Node, ResolvedPos } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { WidgetProps } from "@shared/editor/lib/Extension";
import { PlaceholderPlugin } from "@shared/editor/plugins/PlaceholderPlugin";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import Suggestion from "~/editor/extensions/Suggestion";
import BlockMenu from "../components/BlockMenu";
@@ -27,7 +29,10 @@ export default class BlockMenuExtension extends Suggestion {
const button = document.createElement("button");
button.className = "block-menu-trigger";
button.type = "button";
ReactDOM.render(<PlusIcon />, button);
button.addEventListener("click", this.handleClick);
const root = createRoot(button);
root.render(<PlusIcon />);
return button;
return [
...super.plugins,
@@ -49,7 +54,6 @@ export default class BlockMenuExtension extends Suggestion {
const decorations: Decoration[] = [];
const isEmptyNode = parent && parent.node.content.size === 0;
const isSlash = parent && parent.node.textContent === "/";
if (isEmptyNode) {
decorations.push(
@@ -69,33 +73,39 @@ export default class BlockMenuExtension extends Suggestion {
}
)
);
const isEmptyDoc = state.doc.textContent === "";
if (!isEmptyDoc) {
decorations.push(
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{
class: "placeholder",
"data-empty-text": this.options.dictionary.newLineEmpty,
}
)
);
}
} else if (isSlash) {
decorations.push(
Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
class: "placeholder",
"data-empty-text": ` ${this.options.dictionary.newLineWithSlash}`,
})
);
}
return DecorationSet.create(state.doc, decorations);
},
},
}),
new PlaceholderPlugin([
{
condition: (
node: Node,
$start: ResolvedPos,
_parent: Node | null,
state: EditorState
) =>
$start.depth === 1 &&
node.textContent === "" &&
!!state.doc.textContent &&
state.selection.$from.pos === $start.pos + node.content.size,
text: this.options.dictionary.newLineEmpty,
},
{
condition: (
node: Node,
$start: ResolvedPos,
_parent: Node,
state: EditorState
) =>
$start.depth === 1 &&
node.textContent === "/" &&
state.selection.$from.pos === $start.pos + node.content.size,
text: ` ${this.options.dictionary.newLineWithSlash}`,
},
]),
];
}
+1 -2
View File
@@ -18,8 +18,7 @@ export default function useEmbeds(loadIfMissing = false) {
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchPage({
limit: 100,
await integrations.fetchAll({
type: IntegrationType.Embed,
});
} catch (err) {
+51 -25
View File
@@ -13,20 +13,21 @@ import {
ImportIcon,
ShapesIcon,
Icon,
PlusIcon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { useComputed } from "./useComputed";
import useCurrentTeam from "./useCurrentTeam";
import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
@@ -43,30 +44,38 @@ const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
component: React.ComponentType;
description?: string;
preload?: () => void;
enabled: boolean;
group: string;
pluginId?: string;
};
const useSettingsConfig = () => {
const { integrations } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
const can = usePolicy(team);
const { t } = useTranslation();
React.useEffect(() => {
void integrations.fetchAll();
}, [integrations]);
const config = useComputed(() => {
const items: ConfigItem[] = [
// Account
{
name: t("Profile"),
path: settingsPath(),
component: Profile,
component: Profile.Component,
preload: Profile.preload,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
@@ -74,7 +83,8 @@ const useSettingsConfig = () => {
{
name: t("Preferences"),
path: settingsPath("preferences"),
component: Preferences,
component: Preferences.Component,
preload: Preferences.preload,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
@@ -82,7 +92,8 @@ const useSettingsConfig = () => {
{
name: t("Notifications"),
path: settingsPath("notifications"),
component: Notifications,
component: Notifications.Component,
preload: Notifications.preload,
enabled: true,
group: t("Account"),
icon: EmailIcon,
@@ -90,7 +101,8 @@ const useSettingsConfig = () => {
{
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps,
component: APIAndApps.Component,
preload: APIAndApps.preload,
enabled: true,
group: t("Account"),
icon: PadlockIcon,
@@ -99,7 +111,8 @@ const useSettingsConfig = () => {
{
name: t("Details"),
path: settingsPath("details"),
component: Details,
component: Details.Component,
preload: Details.preload,
enabled: can.update,
group: t("Workspace"),
icon: TeamIcon,
@@ -107,7 +120,8 @@ const useSettingsConfig = () => {
{
name: t("Security"),
path: settingsPath("security"),
component: Security,
component: Security.Component,
preload: Security.preload,
enabled: can.update,
group: t("Workspace"),
icon: PadlockIcon,
@@ -115,7 +129,8 @@ const useSettingsConfig = () => {
{
name: t("Features"),
path: settingsPath("features"),
component: Features,
component: Features.Component,
preload: Features.preload,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
@@ -123,7 +138,8 @@ const useSettingsConfig = () => {
{
name: t("Members"),
path: settingsPath("members"),
component: Members,
component: Members.Component,
preload: Members.preload,
enabled: can.listUsers,
group: t("Workspace"),
icon: UserIcon,
@@ -131,7 +147,8 @@ const useSettingsConfig = () => {
{
name: t("Groups"),
path: settingsPath("groups"),
component: Groups,
component: Groups.Component,
preload: Groups.preload,
enabled: can.listGroups,
group: t("Workspace"),
icon: GroupIcon,
@@ -139,7 +156,8 @@ const useSettingsConfig = () => {
{
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
component: Templates.Component,
preload: Templates.preload,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
@@ -147,7 +165,8 @@ const useSettingsConfig = () => {
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys,
component: ApiKeys.Component,
preload: ApiKeys.preload,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
@@ -155,7 +174,8 @@ const useSettingsConfig = () => {
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications,
component: Applications.Component,
preload: Applications.preload,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
@@ -163,7 +183,8 @@ const useSettingsConfig = () => {
{
name: t("Shared Links"),
path: settingsPath("shares"),
component: Shares,
component: Shares.Component,
preload: Shares.preload,
enabled: can.listShares,
group: t("Workspace"),
icon: GlobeIcon,
@@ -171,7 +192,8 @@ const useSettingsConfig = () => {
{
name: t("Import"),
path: settingsPath("import"),
component: Import,
component: Import.Component,
preload: Import.preload,
enabled: can.createImport,
group: t("Workspace"),
icon: ImportIcon,
@@ -179,19 +201,20 @@ const useSettingsConfig = () => {
{
name: t("Export"),
path: settingsPath("export"),
component: Export,
component: Export.Component,
preload: Export.preload,
enabled: can.createExport,
group: t("Workspace"),
icon: ExportIcon,
},
// Integrations
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
component: Zapier,
enabled: can.update && isCloudHosted,
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
enabled: true,
group: t("Integrations"),
icon: ZapierIcon,
icon: PlusIcon,
},
];
@@ -208,7 +231,10 @@ const useSettingsConfig = () => {
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
component: plugin.value.component,
pluginId: plugin.id,
description: plugin.value.description,
component: plugin.value.component.Component,
preload: plugin.value.component.preload,
enabled: plugin.value.enabled
? plugin.value.enabled(team, user)
: can.update,
+88
View File
@@ -0,0 +1,88 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
type Props = {
/** The document to which the templates will be applied */
document: Document;
/** Callback to handle when a template is selected */
onSelectTemplate?: (template: Document) => void;
};
/**
* This hook provides a memoized list of menu items for both collection-specific
* templates and workspace-wide templates. It filters templates based on whether
* they are published and organizes them into appropriate sections.
*
* Collection-specific templates are displayed first, followed by workspace templates
* with a separator in between (if both types exist).
*
* @returns An array of MenuItem objects representing templates that can be applied
* to the current document. Returns an empty array if no callback is provided.
*/
export function useTemplateMenuItems({ document, onSelectTemplate }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templateToMenuItem = React.useCallback(
(template: Document): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(
template.titleWithDefault,
user
),
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate?.(template),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter(
(template) => template.publishedAt
);
const collectionItems = templates
.filter(
(template) =>
!template.isWorkspaceTemplate &&
template.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = React.useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
if (!onSelectTemplate) {
return [];
}
return collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { LazyMotion } from "framer-motion";
import { KBarProvider } from "kbar";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { Router } from "react-router-dom";
import stores from "~/stores";
@@ -79,7 +79,8 @@ if (element) {
</React.StrictMode>
);
render(<App />, element);
const root = createRoot(element);
root.render(<App />);
}
window.addEventListener("load", async () => {
+24 -1
View File
@@ -2,7 +2,13 @@ import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import noop from "lodash/noop";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import {
EditIcon,
InputIcon,
RestoreIcon,
SearchIcon,
ShapesIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -57,6 +63,7 @@ import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
import { MenuItem, MenuItemButton } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
@@ -76,6 +83,7 @@ type Props = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
/** Invoked when the "Find and replace" menu item is clicked */
onFindAndReplace?: () => void;
onSelectTemplate?: (template: Document) => void;
/** Invoked when the "Rename" menu item is clicked */
onRename?: () => void;
/** Invoked when menu is opened */
@@ -147,6 +155,7 @@ type MenuContentProps = {
onOpen?: () => void;
onClose?: () => void;
onFindAndReplace?: () => void;
onSelectTemplate?: (template: Document) => void;
onRename?: () => void;
showDisplayOptions?: boolean;
showToggleEmbeds?: boolean;
@@ -156,6 +165,7 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onOpen,
onClose,
onFindAndReplace,
onSelectTemplate,
onRename,
showDisplayOptions,
showToggleEmbeds,
@@ -218,6 +228,11 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
[collections.orderedData, handleRestore, policies]
);
const templateMenuItems = useTemplateMenuItems({
document,
onSelectTemplate,
});
return !isEmpty(can) ? (
<ContextMenu
{...menuState}
@@ -310,6 +325,12 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(moveTemplate, context),
{
type: "submenu",
title: t("Apply template"),
icon: <ShapesIcon />,
items: templateMenuItems,
},
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
@@ -383,6 +404,7 @@ function DocumentMenu({
modal = true,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
label,
onRename,
onOpen,
@@ -466,6 +488,7 @@ function DocumentMenu({
onOpen={onOpen}
onClose={onClose}
onRename={onRename}
onSelectTemplate={onSelectTemplate}
showDisplayOptions={showDisplayOptions}
showToggleEmbeds={showToggleEmbeds}
/>
+4 -53
View File
@@ -1,17 +1,13 @@
import { observer } from "mobx-react";
import { DocumentIcon, ShapesIcon } from "outline-icons";
import { ShapesIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Icon from "@shared/components/Icon";
import { TextHelper } from "@shared/utils/TextHelper";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
type Props = {
/** The document to which the templates will be applied */
@@ -23,57 +19,12 @@ type Props = {
};
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: TextHelper.replaceTemplateVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate(tmpl),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter((tmpl) => tmpl.publishedAt);
const collectionItems = templates
.filter(
(tmpl) =>
!tmpl.isWorkspaceTemplate && tmpl.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = React.useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
const items = collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
const items = useTemplateMenuItems({ onSelectTemplate, document });
if (!items.length) {
return null;
+6 -8
View File
@@ -12,7 +12,7 @@ import {
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
@@ -139,11 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
const hasOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<Icon
value={collection.icon ?? "collection"}
color={collection.color || undefined}
size={40}
/>
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const tabProps = (path: CollectionPath) => ({
@@ -162,7 +158,7 @@ const CollectionScene = observer(function _CollectionScene() {
left={
collection.isArchived ? (
<CollectionBreadcrumb collection={collection} />
) : collection.isEmpty ? undefined : (
) : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
@@ -206,7 +202,9 @@ const CollectionScene = observer(function _CollectionScene() {
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
/>
>
{fallbackIcon}
</IconPicker>
</React.Suspense>
) : (
fallbackIcon
+19 -9
View File
@@ -4,7 +4,7 @@ import isEqual from "lodash/isEqual";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Node } from "prosemirror-model";
import { AllSelection } from "prosemirror-state";
import { AllSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import {
@@ -146,7 +146,17 @@ class DocumentScene extends React.Component<Props> {
}
}
replaceDocument = (template: Document | Revision) => {
/**
* Replaces the given selection with a template, if no selection is provided
* then the template is inserted at the beginning of the document.
*
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (
template: Document | Revision,
selection?: TextSelection | AllSelection
) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -154,6 +164,7 @@ class DocumentScene extends React.Component<Props> {
}
const { view, schema } = editorRef;
const sel = selection ?? TextSelection.near(view.state.doc.resolve(0));
const doc = Node.fromJSON(
schema,
ProsemirrorHelper.replaceTemplateVariables(
@@ -163,11 +174,7 @@ class DocumentScene extends React.Component<Props> {
);
if (doc) {
view.dispatch(
view.state.tr
.setSelection(new AllSelection(view.state.doc))
.replaceSelectionWith(doc)
);
view.dispatch(view.state.tr.setSelection(sel).replaceSelectionWith(doc));
}
this.isEditorDirty = true;
@@ -217,7 +224,10 @@ class DocumentScene extends React.Component<Props> {
});
if (response) {
await this.replaceDocument(response.data);
await this.replaceSelection(
response.data,
new AllSelection(editorRef.view.state.doc)
);
toast.success(t("Document restored"));
history.replace(this.props.document.url, history.location.state);
}
@@ -518,7 +528,7 @@ class DocumentScene extends React.Component<Props> {
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceDocument}
onSelectTemplate={this.replaceSelection}
onSave={this.onSave}
/>
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
@@ -387,6 +387,7 @@ function DocumentHeader({
neutral
/>
)}
onSelectTemplate={onSelectTemplate}
onFindAndReplace={editor?.commands.openFindAndReplace}
showToggleEmbeds={canToggleEmbeds}
showDisplayOptions
+4
View File
@@ -415,6 +415,10 @@ function KeyboardShortcuts() {
shortcut: <Key>---</Key>,
label: t("Horizontal divider"),
},
{
shortcut: <Key>{"|--"}</Key>,
label: t("Table"),
},
{
shortcut: <Key>{"```"}</Key>,
label: t("Code block"),
+1 -3
View File
@@ -67,9 +67,7 @@ function APIAndApps() {
/>
</Text>
) : (
<Trans>
{t("API keys have been disabled by an admin for your account")}
</Trans>
<Trans>API keys have been disabled by an admin for your account</Trans>
)}
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
+72
View File
@@ -0,0 +1,72 @@
import groupBy from "lodash/groupBy";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
export function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
const [query, setQuery] = React.useState("");
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
};
const groupedItems = groupBy(
items.filter(
(item) =>
item.group === "Integrations" &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
),
(item) =>
item.pluginId && integrations.findByService(item.pluginId)
? "connected"
: "available"
);
return (
<Scene title={t("Integrations")}>
<Heading>{t("Integrations")}</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a variety of integrations with third-party services.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleQuery}
/>
</StickyFilters>
<Cards gap={30} wrap>
{groupedItems.connected?.map((item) => (
<IntegrationCard key={item.path} integration={item} isConnected />
))}
{groupedItems.available?.map((item) => (
<IntegrationCard key={item.path} integration={item} />
))}
</Cards>
</Scene>
);
}
const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
@@ -0,0 +1,93 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { ConfigItem } from "~/hooks/useSettingsConfig";
import Button from "../../../components/Button";
import Flex from "../../../components/Flex";
import Text from "../../../components/Text";
type Props = {
integration: ConfigItem;
isConnected?: boolean;
};
function IntegrationCard({ integration, isConnected }: Props) {
const { t } = useTranslation();
return (
<Card as={Link} to={integration.path}>
<Flex align="center" gap={8}>
<integration.icon size={48} />
<Flex auto column>
<Name>{integration.name}</Name>
{isConnected && <Status>{t("Connected")}</Status>}
</Flex>
<Button as="span" neutral>
{isConnected ? t("Configure") : t("Connect")}
</Button>
</Flex>
<Description>{integration.description}</Description>
</Card>
);
}
export default IntegrationCard;
const Card = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 20px;
width: 300px;
background: ${s("background")};
border: 1px solid ${s("inputBorder")};
color: ${s("text")};
border-radius: 8px;
transition: box-shadow 200ms ease;
cursor: var(--pointer);
&:hover {
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
}
`;
const Name = styled(Text)`
margin: 0;
font-size: 16px;
font-weight: 600;
color: ${s("text")};
${ellipsis()}
`;
const Description = styled(Text)`
margin: 8px 0 0;
font-size: 15px;
max-width: 100%;
color: ${s("textTertiary")};
`;
const Status = styled(Text).attrs({
type: "secondary",
size: "small",
as: "span",
})`
display: inline-flex;
align-items: center;
&::after {
content: "";
display: inline-block;
width: 17px;
height: 17px;
background: radial-gradient(
circle at center,
${s("accent")} 0 33%,
transparent 33%
);
border-radius: 50%;
}
`;
@@ -0,0 +1,33 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Breadcrumb from "~/components/Breadcrumb";
import Scene from "~/components/Scene";
import { settingsPath } from "~/utils/routeHelpers";
export function IntegrationScene({
children,
...rest
}: React.ComponentProps<typeof Scene>) {
const { t } = useTranslation();
return (
<Scene
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Integrations"),
icon: <SettingsIcon />,
to: settingsPath("integrations"),
},
]}
/>
}
{...rest}
>
{children}
</Scene>
);
}
+2 -7
View File
@@ -279,19 +279,14 @@ export default class DocumentsStore extends Store<Document> {
@action
fetchBacklinks = async (documentId: string): Promise<void> => {
const res = await client.post(`/documents.list`, {
const documents = await this.fetchAll({
backlinkDocumentId: documentId,
});
invariant(res?.data, "Document list not available");
const { data } = res;
runInAction("DocumentsStore#fetchBacklinks", () => {
data.forEach(this.add);
this.addPolicies(res.policies);
this.backlinks.set(
documentId,
data.map((doc: Partial<Document>) => doc.id)
documents.map((doc) => doc.id)
);
});
};
+6
View File
@@ -10,6 +10,12 @@ class IntegrationsStore extends Store<Integration> {
super(rootStore, Integration);
}
findByService(service: string) {
return this.orderedData.find(
(integration) => integration.service === service
);
}
@computed
get orderedData(): Integration[] {
return naturalSort(Array.from(this.data.values()), "name");
+6
View File
@@ -104,6 +104,9 @@ export default abstract class Store<T extends Model> {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
@@ -114,6 +117,9 @@ export default abstract class Store<T extends Model> {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("archivedAt" in item && item.archivedAt) {
return false;
}
if ("searchContent" in item) {
const seachables =
typeof item.searchContent === "string"
+5 -2
View File
@@ -3,6 +3,7 @@ import sortBy from "lodash/sortBy";
import { action, observable } from "mobx";
import Team from "~/models/Team";
import User from "~/models/User";
import { LazyComponent } from "~/components/LazyLoad";
import { useComputed } from "~/hooks/useComputed";
import Logger from "./Logger";
import isCloudHosted from "./isCloudHosted";
@@ -27,8 +28,10 @@ type PluginValueMap = {
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The settings screen somponent, should be lazy loaded. */
component: React.LazyExoticComponent<React.ComponentType>;
/** The lazy loaded settings screen component. */
component: LazyComponent<React.ComponentType>;
/** The description that will show on the plugins card. */
description?: string;
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
+21 -19
View File
@@ -14,6 +14,7 @@
"dev:backend": "NODE_ENV=development nodemon --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --ignore *.test.ts --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
"lint": "eslint app server shared plugins",
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r yarn eslint --fix",
"prepare": "husky install",
"postinstall": "yarn patch-package",
"install-local-ssl": "node ./server/scripts/install-local-ssl.js",
@@ -48,17 +49,17 @@
"> 0.25%, not dead"
],
"dependencies": {
"@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",
"@aws-sdk/client-s3": "3.812.0",
"@aws-sdk/lib-storage": "3.812.0",
"@aws-sdk/s3-presigned-post": "3.812.0",
"@aws-sdk/s3-request-presigner": "3.812.0",
"@aws-sdk/signature-v4-crt": "^3.812.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-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
@@ -86,15 +87,15 @@
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.6",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.5",
"@types/mailparser": "^3.4.6",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
@@ -141,7 +142,7 @@
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.21",
"katex": "^0.16.22",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
@@ -197,13 +198,13 @@
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
"react": "^17.0.2",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.2",
"react-color": "^2.17.3",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",
"react-dom": "^18.2.0",
"react-dropzone": "^11.7.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
@@ -263,11 +264,12 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.1",
"@babel/cli": "^7.27.2",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
"@types/crypto-js": "^4.2.2",
@@ -307,10 +309,10 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react": "^18.2.0",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^17.0.11",
"@types/react-dom": "^18.2.0",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
@@ -343,7 +345,7 @@
"discord-api-types": "^0.37.119",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -361,7 +363,7 @@
"nodemon": "^3.1.10",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.5",
"terser": "^5.39.0",
@@ -379,6 +381,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.83.0",
"version": "0.84.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+50 -52
View File
@@ -1,5 +1,5 @@
import Router from "koa-router";
import { NotificationEventType } from "@shared/types";
import { Client, NotificationEventType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import SigninEmail from "@server/emails/templates/SigninEmail";
@@ -86,66 +86,64 @@ router.post(
}
);
router.get(
"email.callback",
validate(T.EmailCallbackSchema),
async (ctx: APIContext<T.EmailCallbackReq>) => {
const { token, client, follow } = ctx.input.query;
const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
const token = ctx.input.query?.token || ctx.input.body?.token;
const client = ctx.input.query?.client || ctx.input.body?.client;
const follow = ctx.input.query?.follow || ctx.input.body?.follow;
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true", "POST");
}
let user!: User;
let user!: User;
try {
user = await getUserForEmailSigninToken(token as string);
} catch (err) {
ctx.redirect(`/?notice=expired-token`);
return;
}
try {
user = await getUserForEmailSigninToken(token as string);
} catch (err) {
ctx.redirect(`/?notice=expired-token`);
return;
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect("/?notice=auth-error");
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect("/?notice=auth-error");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=user-suspended");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=user-suspended");
}
if (user.isInvited) {
await new WelcomeEmail({
to: user.email,
role: user.role,
if (user.isInvited) {
await new WelcomeEmail({
to: user.email,
role: user.role,
teamUrl: user.team.url,
}).schedule();
const inviter = await user.$get("invitedBy");
if (inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)) {
await new InviteAcceptedEmail({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
}).schedule();
const inviter = await user.$get("invitedBy");
if (
inviter?.subscribedToEventType(NotificationEventType.InviteAccepted)
) {
await new InviteAcceptedEmail({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
}).schedule();
}
}
// set cookies on response and redirect to team subdomain
await signIn(ctx, "email", {
user,
team: user.team,
isNewTeam: false,
isNewUser: false,
client,
});
}
);
// set cookies on response and redirect to team subdomain
await signIn(ctx, "email", {
user,
team: user.team,
isNewTeam: false,
isNewUser: false,
client: client ?? Client.Web,
});
};
router.get("email.callback", validate(T.EmailCallbackSchema), emailCallback);
router.post("email.callback", validate(T.EmailCallbackSchema), emailCallback);
export default router;
+6 -1
View File
@@ -13,7 +13,12 @@ export type EmailReq = z.infer<typeof EmailSchema>;
export const EmailCallbackSchema = BaseSchema.extend({
query: z.object({
token: z.string(),
token: z.string().optional(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
body: z.object({
token: z.string().optional(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
+3 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -11,7 +12,6 @@ import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
@@ -38,7 +38,7 @@ function GitHub() {
}, [integrations]);
return (
<Scene title="GitHub" icon={<GitHubIcon />}>
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
@@ -146,7 +146,7 @@ function GitHub() {
</Trans>
</Notice>
)}
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+3 -9
View File
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
@@ -40,12 +40,6 @@ function GoogleAnalytics() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
@@ -75,7 +69,7 @@ function GoogleAnalytics() {
);
return (
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Heading>{t("Google Analytics")}</Heading>
<Text as="p" type="secondary">
@@ -100,7 +94,7 @@ function GoogleAnalytics() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "googleanalytics",
"id": "google-analytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+3 -9
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -42,12 +42,6 @@ function Matomo() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
measurementId: integration?.settings.measurementId,
@@ -82,7 +76,7 @@ function Matomo() {
);
return (
<Scene title="Matomo" icon={<Icon />}>
<IntegrationScene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
@@ -121,7 +115,7 @@ function Matomo() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+37 -7
View File
@@ -1,4 +1,5 @@
import passport from "@outlinewiki/koa-passport";
import JWT from "jsonwebtoken";
import type { Context } from "koa";
import Router from "koa-router";
import get from "lodash/get";
@@ -9,6 +10,7 @@ import {
OIDCMalformedUserInfoError,
AuthenticationError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import passportMiddleware from "@server/middlewares/passport";
import { AuthenticationProvider, User } from "@server/models";
import { AuthenticationResult } from "@server/types";
@@ -58,7 +60,7 @@ if (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
params: { expires_in: number; id_token: string },
_profile: unknown,
done: (
err: Error | null,
@@ -78,14 +80,39 @@ if (
accessToken
);
if (!profile.email) {
// Some providers, namely ADFS, don't provide anything more than the `sub` claim in the userinfo endpoint
// So, we'll decode the params.id_token and see if that contains what we need.
const token = (() => {
try {
const decoded = JWT.decode(params.id_token);
if (!decoded || typeof decoded !== "object") {
Logger.warn("Decoded id_token is not a valid object");
return {};
}
return decoded as {
email?: string;
preferred_username?: string;
sub?: string;
};
} catch (err) {
Logger.error("id_token decode threw error: ", err);
return {};
}
})();
const email = profile.email ?? token.email ?? null;
if (!email) {
throw AuthenticationError(
`An email field was not returned in the profile parameter, but is required.`
`An email field was not returned in the profile or id_token parameter, but is required.`
);
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const { domain } = parseEmail(profile.email);
const { domain } = parseEmail(email);
// Only a single OIDC provider is supported find the existing, if any.
const authenticationProvider = team
@@ -118,13 +145,16 @@ if (
// Claim name can be overriden using an env variable.
// Default is 'preferred_username' as per OIDC spec.
const username = get(profile, env.OIDC_USERNAME_CLAIM);
// This will default to the profile.preferred_username, but will fall back to preferred_username from the id_token
const username =
get(profile, env.OIDC_USERNAME_CLAIM) ??
get(token, env.OIDC_USERNAME_CLAIM);
const name = profile.name || username || profile.username;
const profileId = profile.sub ? profile.sub : profile.id;
if (!name) {
throw AuthenticationError(
`Neither a name or username was returned in the profile parameter, but at least one is required.`
`Neither a ${env.OIDC_USERNAME_CLAIM}, name or username was returned in the profile parameter, but at least one is required.`
);
}
@@ -138,7 +168,7 @@ if (
},
user: {
name,
email: profile.email,
email,
avatarUrl: profile.picture,
},
authenticationProvider: {
+4 -10
View File
@@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -34,13 +34,7 @@ function Slack() {
const error = query.get("error");
React.useEffect(() => {
void collections.fetchPage({
limit: 100,
});
void integrations.fetchPage({
service: IntegrationService.Slack,
limit: 100,
});
void collections.fetchAll();
}, [collections, integrations]);
const commandIntegration = integrations.find({
@@ -67,7 +61,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon />}>
<IntegrationScene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -205,7 +199,7 @@ function Slack() {
</List>
</>
)}
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) =>
[UserRole.Member, UserRole.Admin].includes(user.role),
},
+3 -3
View File
@@ -156,9 +156,9 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
switch (type) {
case IntegrationType.Post: {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
authorize(user, "update", user.team);
+3 -9
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -44,12 +44,6 @@ function Umami() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
umamiWebsiteId: integration?.settings.measurementId,
@@ -85,7 +79,7 @@ function Umami() {
);
return (
<Scene title="Umami" icon={<Icon />}>
<IntegrationScene title="Umami" icon={<Icon />}>
<Heading>Umami</Heading>
<Text as="p" type="secondary">
@@ -145,7 +139,7 @@ function Umami() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+6 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -8,9 +8,12 @@ PluginManager.add([
...config,
type: Hook.Settings,
value: {
group: "Integrations",
group: "Workspace",
after: "Shared Links",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
@@ -1,20 +1,22 @@
import * as React from "react";
import { Helmet } from "react-helmet-async";
import { Trans } from "react-i18next";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import Heading from "~/components/Heading";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ZapierIcon from "./Icon";
function Zapier() {
const { ui } = useStores();
const user = useCurrentUser();
const { resolvedTheme } = ui;
const appName = env.APP_NAME;
return (
<Scene title="Zapier" icon={<ZapierIcon />}>
<IntegrationScene title="Zapier" icon={<ZapierIcon />}>
<Heading>Zapier</Heading>
<Helmet>
<script
@@ -39,14 +41,15 @@ function Zapier() {
<zapier-app-directory
app="outline"
link-target="new-tab"
theme={resolvedTheme}
sign-up-email={user.email}
theme={resolvedTheme === "system" ? undefined : resolvedTheme}
hide="notion,confluence-cloud,confluence,google-docs,slack"
applimit={6}
introcopy="hide"
create-without-template="show"
use-this-zap="show"
/>
</Scene>
</IntegrationScene>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Outline workspace to Zapier to automate workflows and integrate with thousands of other tools.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "zapier",
"name": "Zapier",
"description": "Adds a settings screen for connecting to Zapier.",
"deployments": ["cloud"]
}
+8 -12
View File
@@ -116,12 +116,10 @@ export default async function loadDocument({
if (canReadDocument) {
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
rejectOnEmpty: true,
});
}
return {
@@ -140,12 +138,10 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
rejectOnEmpty: true,
});
}
if (!collection?.sharing) {
+12 -17
View File
@@ -65,21 +65,18 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
const collection = await Collection.findByPk(document.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
newCollection = await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -148,13 +145,11 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId, {
transaction,
newCollection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
transaction,
});
result.collections.push(newCollection);
+6 -1
View File
@@ -296,7 +296,12 @@ export default abstract class BaseEmail<
return undefined;
}
let content = ProsemirrorHelper.toHTML(node, {
// Process user mentions to ensure they are uptodate with database
const processedNode = ProsemirrorHelper.toProsemirror(
await ProsemirrorHelper.processMentions(node)
);
let content = ProsemirrorHelper.toHTML(processedNode, {
centered: false,
});
@@ -37,9 +37,10 @@ export default class CollectionCreatedEmail extends BaseEmail<
}
protected async beforeSend(props: InputProps) {
const collection = await Collection.scope("withUser").findByPk(
props.collectionId
);
const collection = await Collection.findByPk(props.collectionId, {
includeOwner: true,
});
if (!collection) {
return false;
}
+46 -5
View File
@@ -13,6 +13,7 @@ import Router from "koa-router";
import { AddressInfo } from "net";
import stoppable from "stoppable";
import throng from "throng";
import escape from "lodash/escape";
import Logger from "./logging/Logger";
import services from "./services";
import { getArg } from "./utils/args";
@@ -88,13 +89,53 @@ async function start(_id: number, disconnect: () => void) {
app.use(defaultRateLimiter());
/** Perform a redirect on the browser so that the user's auth cookies are included in the request. */
app.context.redirectOnClient = function (url: string) {
app.context.redirectOnClient = function (
/** The URL to redirect to */
url: string,
/**
* The HTTP method to use for the redirect. Use POST when preventing links in emails from being
* clicked by bots. Otherwise, use GET.
*/
method: "GET" | "POST" = "GET"
) {
this.type = "text/html";
this.body = `
if (method === "POST") {
// For POST method, create a form that auto-submits
const urlObj = new URL(url);
const formAction = `${urlObj.origin}${urlObj.pathname}`;
const searchParams = urlObj.searchParams;
let formFields = "";
searchParams.forEach((value, key) => {
formFields += `<input type="hidden" name="${escape(
key
)}" value="${escape(value)}" />`;
});
this.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
<title>Redirecting…</title>
</head>
<body>
<form id="redirect-form" method="POST" action="${formAction}">
${formFields}
</form>
<script nonce="${this.state.cspNonce}">
document.getElementById('redirect-form').submit();
</script>
</body>
</html>`;
} else {
// Default GET method using meta refresh
this.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${escape(url)}'" />
</head>
</html>`;
}
};
// Add a health check endpoint to all services
@@ -133,7 +174,7 @@ async function start(_id: number, disconnect: () => void) {
server.on("error", (err) => {
if ("code" in err && err.code === "EADDRINUSE") {
Logger.error(`Port ${normalizedPort} is already in use. Exiting…`, err);
Logger.error(`Port ${normalizedPort} is already in use. Exiting…`, err);
process.exit(0);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { Next } from "koa";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import { Team } from "@server/models";
import { APIContext } from "@server/types";
@@ -37,7 +38,7 @@ export default function apexAuthRedirect<T>({
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
attributes: ["id", "subdomain"],
attributes: ["id", "domain", "subdomain"],
rejectOnEmpty: true,
});
@@ -45,6 +46,7 @@ export default function apexAuthRedirect<T>({
? ctx.redirect("/")
: ctx.redirectOnClient(getRedirectPath(ctx, team));
} catch (err) {
Logger.error("Error fetching team", err);
return ctx.redirect(getErrorPath(ctx));
}
} else {
+61
View File
@@ -0,0 +1,61 @@
import crypto from "crypto";
import { Context, Next } from "koa";
import { contentSecurityPolicy } from "koa-helmet";
import uniq from "lodash/uniq";
import env from "@server/env";
/**
* Create a Content Security Policy middleware for the application.
*/
export default function createCSPMiddleware() {
// Construct scripts CSP based on options in use
const defaultSrc = ["'self'"];
const scriptSrc = ["'self'"];
const styleSrc = ["'self'", "'unsafe-inline'"];
if (env.isCloudHosted) {
scriptSrc.push("www.googletagmanager.com");
scriptSrc.push("cdn.zapier.com");
styleSrc.push("cdn.zapier.com");
}
// Allow to load assets from Vite
if (!env.isProduction) {
scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
scriptSrc.push("localhost:3001");
}
if (env.GOOGLE_ANALYTICS_ID) {
scriptSrc.push("www.googletagmanager.com");
scriptSrc.push("www.google-analytics.com");
}
if (env.CDN_URL) {
scriptSrc.push(env.CDN_URL);
styleSrc.push(env.CDN_URL);
defaultSrc.push(env.CDN_URL);
}
return function cspMiddleware(ctx: Context, next: Next) {
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
return contentSecurityPolicy({
directives: {
defaultSrc,
styleSrc,
scriptSrc: [
...uniq(scriptSrc),
env.DEVELOPMENT_UNSAFE_INLINE_CSP
? "'unsafe-inline'"
: `'nonce-${ctx.state.cspNonce}'`,
],
mediaSrc: ["*", "data:", "blob:"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*", "data:"],
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
connectSrc: ["*"],
},
})(ctx, next);
};
}
+64 -10
View File
@@ -14,6 +14,8 @@ import {
EmptyResultError,
type CreateOptions,
type UpdateOptions,
type ScopeOptions,
type SaveOptions,
} from "sequelize";
import {
Sequelize,
@@ -38,6 +40,7 @@ import {
BeforeCreate,
BeforeUpdate,
DefaultScope,
AfterSave,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -47,6 +50,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
import { CollectionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import { CacheHelper } from "@server/utils/CacheHelper";
import removeIndexCollision from "@server/utils/removeIndexCollision";
import { generateUrlId } from "@server/utils/url";
import { ValidateIndex } from "@server/validation";
@@ -67,6 +71,9 @@ import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
type AdditionalFindOptions = {
userId?: string;
includeDocumentStructure?: boolean;
includeOwner?: boolean;
rejectOnEmpty?: boolean | Error;
};
@@ -330,6 +337,34 @@ class Collection extends ParanoidModel<
if (!model.content) {
model.content = await DocumentHelper.toJSON(model);
}
if (model.changed("documentStructure")) {
await CacheHelper.clearData(
CacheHelper.getCollectionDocumentsKey(model.id)
);
}
}
@AfterSave
static async cacheDocumentStructure(
model: Collection,
options: SaveOptions<Collection>
) {
if (model.changed("documentStructure")) {
const setData = () =>
CacheHelper.setData(
CacheHelper.getCollectionDocumentsKey(model.id),
model.documentStructure,
60
);
if (options.transaction) {
return (options.transaction.parent || options.transaction).afterCommit(
setData
);
}
await setData();
}
}
@BeforeDestroy
@@ -392,8 +427,11 @@ class Collection extends ParanoidModel<
model: Collection,
options: UpdateOptions<Collection>
) {
if (model.index && model.changed("index")) {
model.index = await removeIndexCollision(model.teamId, model.index, {
if (
(model.index && model.changed("index")) ||
(!model.archivedAt && model.changed("archivedAt"))
) {
model.index = await removeIndexCollision(model.teamId, model.index!, {
transaction: options.transaction,
});
}
@@ -466,9 +504,9 @@ class Collection extends ParanoidModel<
* @returns userIds
*/
static async membershipUserIds(collectionId: string) {
const collection = await this.scope("withAllMemberships").findByPk(
collectionId
);
const collection = await this.scope("withAllMemberships").findOne({
where: { id: collectionId },
});
if (!collection) {
return [];
}
@@ -485,6 +523,7 @@ class Collection extends ParanoidModel<
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
* and loading memberships for a user passed in by `userId`
*
* @param id uuid or urlId
* @param options FindOptions
@@ -506,16 +545,31 @@ class Collection extends ParanoidModel<
return null;
}
const { includeDocumentStructure, includeOwner, userId, ...rest } = options;
const scopes: (string | ScopeOptions)[] = [
includeDocumentStructure ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", userId],
},
];
if (includeOwner) {
scopes.push("withUser");
}
const scope = this.scope(scopes);
if (isUUID(id)) {
const collection = await this.findOne({
const collection = await scope.findOne({
where: {
id,
},
...options,
...rest,
rejectOnEmpty: false,
});
if (!collection && options.rejectOnEmpty) {
if (!collection && rest.rejectOnEmpty) {
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
}
@@ -524,7 +578,7 @@ class Collection extends ParanoidModel<
const match = id.match(UrlHelper.SLUG_URL_REGEX);
if (match) {
const collection = await this.findOne({
const collection = await scope.findOne({
where: {
urlId: match[1],
},
@@ -532,7 +586,7 @@ class Collection extends ParanoidModel<
rejectOnEmpty: false,
});
if (!collection && options.rejectOnEmpty) {
if (!collection && rest.rejectOnEmpty) {
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
}
+37 -43
View File
@@ -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,21 +110,14 @@ type AdditionalFindOptions = {
},
},
attributes: {
exclude: ["state"],
include: [stateIfContentEmpty],
},
}))
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@Scopes(() => ({
withoutState: {
attributes: {
include: [
Sequelize.literal(
// 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.
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
),
],
include: [stateIfContentEmpty],
},
},
withCollection: {
@@ -130,7 +131,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
exclude: [],
include: [],
},
},
withDrafts: {
@@ -425,13 +426,11 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
const collection = await Collection.findByPk(model.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (!collection) {
return;
}
@@ -452,9 +451,8 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
const collection = await Collection.findByPk(model.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -666,10 +664,11 @@ class Document extends ArchivableModel<
/**
* Overrides the standard findByPk behavior to allow also querying by urlId
* and loading memberships for a user passed in by `userId`
*
* @param id uuid or urlId
* @param options FindOptions
* @returns A promise resolving to a collection instance or null
* @returns A promise resolving to a document instance or null
*/
static async findByPk(
id: Identifier,
@@ -694,7 +693,7 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
options.includeState ? "withState" : "withoutState",
includeState ? "withState" : "withoutState",
{
method: ["withViews", userId],
},
@@ -942,9 +941,8 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
const collection = await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1011,13 +1009,11 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1048,13 +1044,11 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1075,7 +1069,8 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
? await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1127,9 +1122,8 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
const collection = await Collection.findByPk(this.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
+179 -1
View File
@@ -1,9 +1,187 @@
import { faker } from "@faker-js/faker";
import { DeepPartial } from "utility-types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { buildProseMirrorDoc } from "@server/test/factories";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
describe("ProsemirrorHelper", () => {
describe("processMentions", () => {
it("should handle deleted users", async () => {
const user = await buildUser();
const mentionAttrs: MentionAttrs = {
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
type: MentionType.User,
label: "test.user",
actorId: user.id,
modelId: user.id,
};
await user.destroy();
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
type: "paragraph",
content: [
{
type: "text",
text: "a paragraph with ",
},
{
type: "mention",
attrs: mentionAttrs,
},
{
type: "text",
text: " mentioned",
},
],
};
const doc = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "some content in a paragraph",
},
],
},
mentionedParagraph,
]);
const newDoc = await ProsemirrorHelper.processMentions(doc);
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual("Unknown");
});
it("should handle updated users", async () => {
const user = await buildUser();
const mentionAttrs: MentionAttrs = {
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
type: MentionType.User,
label: "test.user",
actorId: user.id,
modelId: user.id,
};
await user.update({
name: faker.name.firstName(),
});
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
type: "paragraph",
content: [
{
type: "text",
text: "a paragraph with ",
},
{
type: "mention",
attrs: mentionAttrs,
},
{
type: "text",
text: " mentioned",
},
],
};
const doc = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "some content in a paragraph",
},
],
},
mentionedParagraph,
]);
const newDoc = await ProsemirrorHelper.processMentions(doc);
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual(user.name);
});
it("should handle multiple renamed users", async () => {
const firstUser = await buildUser();
const secondUser = await buildUser();
const firstMentionAttrs: MentionAttrs = {
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
type: MentionType.User,
label: "first.user",
actorId: firstUser.id,
modelId: firstUser.id,
};
const secondMentionAttrs: MentionAttrs = {
id: "31d5899f-e544-4ff6-b6d3-c49dd6b81901",
type: MentionType.User,
label: "second.user",
actorId: secondUser.id,
modelId: secondUser.id,
};
const firstNewName = faker.name.firstName();
const secondNewName = faker.name.firstName();
await firstUser.update({
name: firstNewName,
});
await secondUser.update({
name: secondNewName,
});
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
type: "paragraph",
content: [
{
type: "text",
text: "a paragraph with ",
},
{
type: "mention",
attrs: firstMentionAttrs,
},
{
type: "text",
text: " and ",
},
{
type: "mention",
attrs: secondMentionAttrs,
},
{
type: "text",
text: " mentioned",
},
],
};
const doc = buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: "some content in a paragraph",
},
],
},
mentionedParagraph,
]);
const newDoc = await ProsemirrorHelper.processMentions(doc);
expect(newDoc.content?.[1]?.content?.[1].attrs?.label).toEqual(
firstNewName
);
expect(newDoc.content?.[1]?.content?.[3].attrs?.label).toEqual(
secondNewName
);
});
});
describe("getNodeForMentionEmail", () => {
it("should return the paragraph node", () => {
const mentionAttrs: MentionAttrs = {
+77 -1
View File
@@ -21,6 +21,7 @@ import { schema, parser } from "@server/editor";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import Attachment from "@server/models/Attachment";
import User from "@server/models/User";
import FileStorage from "@server/storage/files";
export type HTMLOptions = {
@@ -490,7 +491,7 @@ export class ProsemirrorHelper {
// Render the Prosemirror document using virtual DOM and serialize the
// result to a string
const dom = new JSDOM(
`<!DOCTYPE html>${
`<!DOCTYPE html><meta charset="utf-8">${
options?.includeStyles === false ? "" : styleTags
}${html}`
);
@@ -558,4 +559,79 @@ export class ProsemirrorHelper {
return dom.serialize();
}
/**
* Processes mentions in the Prosemirror data, ensuring that mentions
* for deleted users are displayed as "@unknown" and updated names are
* displayed correctly.
*
* @param data The ProsemirrorData object to process
* @returns The processed ProsemirrorData with updated mentions
*/
static async processMentions(data: ProsemirrorData | Node) {
const json = "toJSON" in data ? (data.toJSON() as ProsemirrorData) : data;
// First pass: collect all user IDs from mentions
const userIds: string[] = [];
function collectUserIds(node: ProsemirrorData) {
if (
node.type === "mention" &&
node.attrs?.type === MentionType.User &&
node.attrs?.modelId
) {
userIds.push(node.attrs.modelId as string);
}
if (node.content) {
for (const child of node.content) {
collectUserIds(child);
}
}
}
collectUserIds(json);
// Load all users in a single query
const uniqueUserIds = [...new Set(userIds)];
const users = uniqueUserIds.length
? await User.findAll({
where: {
id: uniqueUserIds,
},
attributes: ["id", "name"],
})
: [];
// Create a map for quick lookup
const userMap = new Map();
users.forEach((user) => {
userMap.set(user.id, user.name);
});
// Second pass: transform mentions with loaded user data
function transformMentions(node: ProsemirrorData) {
if (
node.type === "mention" &&
node.attrs?.type === MentionType.User &&
node.attrs?.modelId
) {
const userId = node.attrs.modelId as string;
node.attrs = {
...node.attrs,
label: userMap.get(userId) || "Unknown",
};
}
if (node.content) {
for (const child of node.content) {
transformMentions(child);
}
}
return node;
}
return transformMentions(json);
}
}
+39 -39
View File
@@ -14,9 +14,9 @@ describe("admin", () => {
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", admin.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: admin.id,
});
const abilities = serialize(admin, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).toBeTruthy();
@@ -36,9 +36,9 @@ describe("admin", () => {
permission: null,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.readDocument).toEqual(false);
expect(abilities.updateDocument).toEqual(false);
@@ -59,9 +59,9 @@ describe("admin", () => {
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.readDocument).toBeTruthy();
expect(abilities.updateDocument).toBeTruthy();
@@ -87,9 +87,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).toBeTruthy();
@@ -116,9 +116,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).toBe(false);
@@ -161,9 +161,9 @@ describe("member", () => {
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -189,9 +189,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.update).not.toBeTruthy();
@@ -232,9 +232,9 @@ describe("member", () => {
},
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: member.id,
});
const abilities = serialize(member, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -279,9 +279,9 @@ describe("member", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -331,9 +331,9 @@ describe("viewer", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -361,9 +361,9 @@ describe("viewer", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -409,9 +409,9 @@ describe("viewer", () => {
permission: CollectionPermission.ReadWrite,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
@@ -462,9 +462,9 @@ describe("guest", () => {
permission: CollectionPermission.Read,
});
// reload to get membership
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
});
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
+18 -12
View File
@@ -353,9 +353,9 @@ export default class WebsocketsProcessor {
case "collections.remove_user": {
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", event.userId],
}).findByPk(event.collectionId),
Collection.findByPk(event.collectionId, {
userId: event.userId,
}),
User.findByPk(event.userId),
]);
if (!user) {
@@ -424,9 +424,9 @@ export default class WebsocketsProcessor {
async (groupUsers) => {
for (const groupUser of groupUsers) {
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(event.collectionId),
Collection.findByPk(event.collectionId, {
userId: groupUser.userId,
}),
User.findByPk(groupUser.userId),
]);
if (!user) {
@@ -716,9 +716,12 @@ export default class WebsocketsProcessor {
presentGroupMembership(groupMembership)
);
const collection = await Collection.scope({
method: ["withMembership", event.userId],
}).findByPk(groupMembership.collectionId);
const collection = await Collection.findByPk(
groupMembership.collectionId,
{
userId: event.userId,
}
);
if (cannot(user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
@@ -772,9 +775,12 @@ export default class WebsocketsProcessor {
.to(`user-${groupUser.userId}`)
.emit("collections.remove_group", payload);
const collection = await Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(groupMembership.collectionId);
const collection = await Collection.findByPk(
groupMembership.collectionId,
{
userId: groupUser.userId,
}
);
if (cannot(groupUser.user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
@@ -17,8 +17,10 @@ export default class CleanupDeletedDocumentsTask extends BaseTask<Props> {
"task",
`Permanently destroying upto ${limit} documents older than 30 days…`
);
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id", "teamId", "content", "text", "deletedAt"],
const documents = await Document.scope([
"withDrafts",
"withoutState",
]).findAll({
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
@@ -16,9 +16,9 @@ export default class CollectionSubscriptionRemoveUserTask extends BaseTask<Colle
return;
}
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
const collection = await Collection.findByPk(event.collectionId, {
userId: user.id,
});
if (can(user, "read", collection)) {
Logger.debug(
@@ -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;
@@ -1917,4 +1917,34 @@ describe("#collections.restore", () => {
expect(body.data.archivedAt).toBe(null);
expect(collection.documentStructure).not.toBe(null);
});
it("should resolve index collision when restoring", async () => {
const admin = await buildAdmin();
let collection = await buildCollection({
teamId: admin.teamId,
});
let archivedCollection = await buildCollection({
teamId: admin.teamId,
archivedAt: new Date(),
archivedById: admin.id,
});
[collection, archivedCollection] = await Promise.all([
collection.update({ index: "P" }, { hooks: false }),
archivedCollection.update({ index: "P" }, { hooks: false }),
]);
expect(collection.index).toEqual("P");
expect(archivedCollection.index).toEqual("P");
const res = await server.post("/api/collections.restore", {
body: {
token: admin.getJwtToken(),
id: archivedCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.archivedAt).toBe(null);
expect(body.data.index).not.toBe("P");
});
});
+51 -49
View File
@@ -1,4 +1,3 @@
import invariant from "invariant";
import Router from "koa-router";
import { Sequelize, Op, WhereOptions } from "sequelize";
import {
@@ -39,6 +38,7 @@ import {
presentFileOperation,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { collectionIndexing } from "@server/utils/indexing";
import pagination from "../middlewares/pagination";
@@ -96,12 +96,11 @@ router.post(
},
});
// we must reload the collection to get memberships for policy presenter
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id, {
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
transaction,
rejectOnEmpty: true,
});
invariant(reloaded, "collection not found");
ctx.body = {
data: await presentCollection(ctx, reloaded),
@@ -118,11 +117,14 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
"defaultScope",
"withArchivedBy",
{
method: ["withMembership", user.id],
},
"withArchivedBy",
]).findByPk(id);
]).findOne({
where: { id },
});
authorize(user, "read", collection);
@@ -140,16 +142,27 @@ 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.findByPk(id, {
userId: user.id,
});
authorize(user, "readDocument", collection);
const documentStructure = await CacheHelper.getDataOrSet(
CacheHelper.getCollectionDocumentsKey(collection.id),
async () =>
(
await Collection.findByPk(collection.id, {
attributes: ["documentStructure"],
includeDocumentStructure: true,
rejectOnEmpty: true,
})
).documentStructure,
60
);
ctx.body = {
data: collection.documentStructure || [],
data: documentStructure || [],
};
}
);
@@ -201,9 +214,7 @@ router.post(
const { user } = ctx.state.auth;
const [collection, group] = await Promise.all([
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction }),
Collection.findByPk(id, { userId: user.id, transaction }),
Group.findByPk(groupId, { transaction }),
]);
authorize(user, "update", collection);
@@ -248,9 +259,8 @@ router.post(
const { transaction } = ctx.state;
const [collection, group] = await Promise.all([
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
Collection.findByPk(id, {
userId: user.id,
transaction,
}),
Group.findByPk(groupId, {
@@ -286,9 +296,9 @@ router.post(
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const collection = await Collection.findByPk(id, {
userId: user.id,
});
authorize(user, "read", collection);
let where: WhereOptions<GroupMembership> = {
@@ -356,9 +366,7 @@ router.post(
const { id, userId, permission } = ctx.input.body;
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
Collection.findByPk(id, { userId, transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
@@ -402,9 +410,7 @@ router.post(
const { id, userId } = ctx.input.body;
const [collection, user] = await Promise.all([
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
Collection.findByPk(id, { userId, transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
@@ -435,9 +441,9 @@ router.post(
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
const collection = await Collection.findByPk(id, {
userId: user.id,
});
authorize(user, "read", collection);
let where: WhereOptions<UserMembership> = {
@@ -503,9 +509,10 @@ router.post(
const team = await Team.findByPk(user.teamId, { transaction });
authorize(user, "createExport", team);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction });
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "export", collection);
const fileOperation = await collectionExporter({
@@ -576,9 +583,8 @@ router.post(
} = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
});
authorize(user, "update", collection);
@@ -814,9 +820,8 @@ router.post(
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.findByPk(id, {
userId: user.id,
transaction,
});
@@ -845,11 +850,8 @@ router.post(
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.findByPk(id, {
userId: user.id,
transaction,
rejectOnEmpty: true,
});
@@ -905,11 +907,11 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
const collection = await Collection.findByPk(id, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
transaction,
});
authorize(user, "restore", collection);
+3 -1
View File
@@ -154,7 +154,9 @@ router.post(
]);
comments.forEach((comment) => (comment.document = document));
} else if (collectionId) {
const collection = await Collection.findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
const include = [
{
+49 -58
View File
@@ -133,12 +133,10 @@ 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([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: sort === "index",
});
authorize(user, "readDocument", collection);
@@ -331,9 +329,9 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
@@ -512,9 +510,9 @@ router.post(
const { user } = ctx.state.auth;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
@@ -821,15 +819,20 @@ router.post(
const destCollectionId = collectionId ?? sourceCollectionId;
const srcCollection = sourceCollectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(sourceCollectionId, { paranoid: false })
? await Collection.findByPk(sourceCollectionId, {
userId: user.id,
includeDocumentStructure: true,
paranoid: false,
transaction,
})
: undefined;
const destCollection = destCollectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(destCollectionId)
? await Collection.findByPk(destCollectionId, {
userId: user.id,
includeDocumentStructure: true,
transaction,
})
: undefined;
// In case of workspace templates, both source and destination collections are undefined.
@@ -931,9 +934,9 @@ router.post(
let collaboratorIds = undefined;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
@@ -1027,9 +1030,9 @@ router.post(
teamId = user.teamId;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "readDocument", collection);
}
@@ -1118,9 +1121,10 @@ router.post(
authorize(user, "update", original);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
} else {
authorize(user, "createTemplate", user.team);
@@ -1205,9 +1209,10 @@ router.post(
collectionId,
"collectionId is required to publish a draft without collection"
);
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId!, { transaction });
collection = await Collection.findByPk(collectionId!, {
userId: user.id,
transaction,
});
}
if (document.parentDocumentId) {
@@ -1261,9 +1266,10 @@ router.post(
authorize(user, "read", document);
const collection = collectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction })
? await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
})
: document?.collection;
if (collection) {
@@ -1323,9 +1329,10 @@ router.post(
authorize(user, "move", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
@@ -1503,13 +1510,8 @@ router.post(
const file = ctx.input.file;
const { user } = ctx.state.auth;
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
},
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "createDocument", collection);
let parentDocument;
@@ -1606,14 +1608,8 @@ router.post(
});
if (parentDocument?.collectionId) {
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: parentDocument.collectionId,
teamId: user.teamId,
},
transaction,
collection = await Collection.findByPk(parentDocument.collectionId, {
userId: user.id,
});
}
@@ -1621,13 +1617,8 @@ router.post(
collection,
});
} else if (collectionId) {
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
},
collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "createDocument", collection);
+3 -3
View File
@@ -61,9 +61,9 @@ router.post(
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
} else {
const collectionIds = await user.collectionIds({
+4 -3
View File
@@ -33,9 +33,10 @@ router.post(
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "update", collection);
authorize(user, "pin", document);
} else {
+4 -3
View File
@@ -55,9 +55,10 @@ router.post(
authorize(user, "read", document);
const collection = document.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
document.collectionId
)
? await Collection.findByPk(document.collectionId, {
userId: user.id,
includeDocumentStructure: true,
})
: undefined;
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
+4 -3
View File
@@ -37,9 +37,10 @@ router.post(
}
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
authorize(user, "star", collection);
}
@@ -34,9 +34,10 @@ router.post(
};
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction: ctx.state.transaction,
});
authorize(user, "read", collection);
where.collectionId = collectionId;
@@ -78,9 +79,9 @@ router.post(
};
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "read", collection);
where.collectionId = collectionId;
@@ -116,9 +117,9 @@ router.post(
const { event, collectionId, documentId } = ctx.input.body;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
authorize(user, "subscribe", collection);
} else {
@@ -1,6 +1,7 @@
import Router from "koa-router";
import { Op } from "sequelize";
import { Sequelize } from "sequelize-typescript";
import { StatusFilter } from "@shared/types";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { User } from "@server/models";
@@ -28,6 +29,7 @@ router.post(
query,
offset,
limit,
statusFilter: [StatusFilter.Published],
}),
User.findAll({
where: {
-2
View File
@@ -260,8 +260,6 @@ router.get(
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
+7 -60
View File
@@ -1,13 +1,8 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import crypto from "crypto";
import { Server } from "https";
import Koa from "koa";
import compress from "koa-compress";
import {
contentSecurityPolicy,
dnsPrefetchControl,
referrerPolicy,
} from "koa-helmet";
import { dnsPrefetchControl, referrerPolicy } from "koa-helmet";
import mount from "koa-mount";
import enforceHttps, {
httpsResolver,
@@ -17,6 +12,7 @@ import { Second } from "@shared/utils/time";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/Metrics";
import csp from "@server/middlewares/csp";
import ShutdownHelper, { ShutdownOrder } from "@server/utils/ShutdownHelper";
import { initI18n } from "@server/utils/i18n";
import routes from "../routes";
@@ -24,32 +20,6 @@ import api from "../routes/api";
import auth from "../routes/auth";
import oauth from "../routes/oauth";
// Construct scripts CSP based on services in use by this installation
const defaultSrc = ["'self'"];
const scriptSrc = ["'self'", "www.googletagmanager.com"];
const styleSrc = ["'self'", "'unsafe-inline'"];
if (env.isCloudHosted) {
scriptSrc.push("cdn.zapier.com");
styleSrc.push("cdn.zapier.com");
}
// Allow to load assets from Vite
if (!env.isProduction) {
scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
scriptSrc.push("localhost:3001");
}
if (env.GOOGLE_ANALYTICS_ID) {
scriptSrc.push("www.google-analytics.com");
}
if (env.CDN_URL) {
scriptSrc.push(env.CDN_URL);
styleSrc.push(env.CDN_URL);
defaultSrc.push(env.CDN_URL);
}
export default function init(app: Koa = new Koa(), server?: Server) {
void initI18n();
@@ -76,10 +46,6 @@ export default function init(app: Koa = new Koa(), server?: Server) {
app.use(compress());
app.use(mount("/oauth", oauth));
app.use(mount("/auth", auth));
app.use(mount("/api", api));
// Monitor server connections
if (server) {
setInterval(() => {
@@ -96,31 +62,10 @@ export default function init(app: Koa = new Koa(), server?: Server) {
Metrics.gaugePerInstance("connections.count", 0);
});
// Sets common security headers by default, such as no-sniff, hsts, hide powered
// by etc, these are applied after auth and api so they are only returned on
// standard non-XHR accessed routes
app.use((ctx, next) => {
ctx.state.cspNonce = crypto.randomBytes(16).toString("hex");
app.use(mount("/api", api));
return contentSecurityPolicy({
directives: {
defaultSrc,
styleSrc,
scriptSrc: [
...scriptSrc,
env.DEVELOPMENT_UNSAFE_INLINE_CSP
? "'unsafe-inline'"
: `'nonce-${ctx.state.cspNonce}'`,
],
mediaSrc: ["*", "data:", "blob:"],
imgSrc: ["*", "data:", "blob:"],
frameSrc: ["*", "data:"],
// Do not use connect-src: because self + websockets does not work in
// Safari, ref: https://bugs.webkit.org/show_bug.cgi?id=201591
connectSrc: ["*"],
},
})(ctx, next);
});
// Apply CSP middleware after API as these responses are rendered in the browser
app.use(csp());
// Allow DNS prefetching for performance, we do not care about leaking requests
// to our own CDN's
@@ -135,6 +80,8 @@ export default function init(app: Koa = new Koa(), server?: Server) {
})
);
app.use(mount("/oauth", oauth));
app.use(mount("/auth", auth));
app.use(mount(routes));
return app;
+3 -3
View File
@@ -191,9 +191,9 @@ async function authenticated(io: IO.Server, socket: SocketWithAuth) {
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
const collection = await Collection.findByPk(event.collectionId, {
userId: user.id,
});
if (can(user, "read", collection)) {
await socket.join(`collection-${event.collectionId}`);
+47
View File
@@ -0,0 +1,47 @@
import { EventEmitter } from "events";
// Create a mock Redis client with all needed methods mocked
class RedisMock extends EventEmitter {
constructor() {
super();
}
get = jest.fn().mockResolvedValue(null);
set = jest.fn().mockResolvedValue("OK");
del = jest.fn().mockResolvedValue(1);
keys = jest.fn().mockResolvedValue([]);
ping = jest.fn().mockResolvedValue("PONG");
disconnect = jest.fn();
setMaxListeners = jest.fn();
}
// Mock the RedisAdapter class
class RedisAdapter extends RedisMock {
constructor(_url: string | undefined, _options = {}) {
super();
}
private static client: RedisAdapter;
private static subscriber: RedisAdapter;
public static get defaultClient(): RedisAdapter {
return (
this.client ||
(this.client = new this(undefined, {
connectionNameSuffix: "client",
}))
);
}
public static get defaultSubscriber(): RedisAdapter {
return (
this.subscriber ||
(this.subscriber = new this(undefined, {
maxRetriesPerRequest: null,
connectionNameSuffix: "subscriber",
}))
);
}
}
export default RedisAdapter;
+1 -1
View File
@@ -10,7 +10,7 @@ import fetch, { chromeUserAgent, RequestInit } from "@server/utils/fetch";
export default abstract class BaseStorage {
/** The default number of seconds until a signed URL expires. */
public static defaultSignedUrlExpires = 60;
public static defaultSignedUrlExpires = 300;
/**
* Returns a presigned post for uploading files to the storage provider.
+28 -3
View File
@@ -8,7 +8,7 @@ import invariant from "invariant";
import JWT from "jsonwebtoken";
import safeResolvePath from "resolve-path";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import { InternalError, ValidationError } from "@server/errors";
import Logger from "@server/logging/Logger";
import BaseStorage from "./BaseStorage";
@@ -132,8 +132,33 @@ export default class LocalStorage extends BaseStorage {
};
}
public getFileStream(key: string, range?: { start: number; end: number }) {
return Promise.resolve(fs.createReadStream(this.getFilePath(key), range));
public async getFileStream(
key: string,
range?: { start: number; end: number }
) {
const filePath = this.getFilePath(key);
const exists = await fs.pathExists(filePath);
if (!exists) {
throw InternalError(`File not found at ${key}`);
}
if (range) {
if (
typeof range.start !== "number" ||
typeof range.end !== "number" ||
range.start < 0 ||
range.end < range.start
) {
throw ValidationError("Invalid range specified");
}
}
try {
return fs.createReadStream(filePath, range);
} catch (err) {
Logger.error(`Failed to create read stream`, err, { filePath });
throw ValidationError("Unable to read file");
}
}
public stat(key: string) {
+3 -3
View File
@@ -416,9 +416,9 @@ export async function buildDocument(
if (overrides.collectionId && overrides.publishedAt !== null) {
collection = collection
? await Collection.scope("withDocumentStructure").findByPk(
overrides.collectionId
)
? await Collection.findByPk(overrides.collectionId, {
includeDocumentStructure: true,
})
: undefined;
await collection?.addDocumentToStructure(document, 0);
+8 -1
View File
@@ -7,6 +7,11 @@ require("@server/storage/database");
jest.mock("bull");
// Enable mocks for Redis-related modules
jest.mock("@server/storage/redis");
jest.mock("@server/utils/MutexLock");
jest.mock("@server/utils/CacheHelper");
// This is needed for the relative manual mock to be picked up
jest.mock("../queues");
@@ -34,7 +39,9 @@ jest.mock("@aws-sdk/s3-request-presigner", () => ({
getSignedUrl: jest.fn(),
}));
afterAll(() => Redis.defaultClient.disconnect());
afterAll(() => {
Redis.defaultClient.disconnect();
});
beforeEach(() => {
env.URL = sharedEnv.URL = "https://app.outline.dev";
+4
View File
@@ -125,4 +125,8 @@ export class CacheHelper {
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
}
+54 -28
View File
@@ -41,7 +41,7 @@ export default class ZipHelper {
prefix: "export-",
postfix: ".zip",
},
(err, path) => {
(err, filePath) => {
if (err) {
return reject(err);
}
@@ -51,13 +51,24 @@ export default class ZipHelper {
currentFile: null,
};
const handleError = (error: Error) => {
dest.destroy();
fs.remove(filePath)
.catch((rmErr) => {
Logger.error("Failed to remove tmp file", rmErr);
})
.finally(() => {
reject(error);
});
};
const dest = fs
.createWriteStream(path)
.createWriteStream(filePath)
.on("finish", () => {
Logger.debug("utils", "Writing zip complete", { path });
return resolve(path);
Logger.debug("utils", "Writing zip complete", { path: filePath });
return resolve(filePath);
})
.on("error", reject);
.on("error", handleError);
zip
.generateNodeStream(
@@ -85,11 +96,9 @@ export default class ZipHelper {
}
}
)
.on("error", (rErr) => {
dest.end();
reject(rErr);
})
.pipe(dest);
.on("error", handleError)
.pipe(dest)
.on("error", handleError);
}
);
});
@@ -126,32 +135,38 @@ export default class ZipHelper {
const fileName = Buffer.from(entry.fileName).toString("utf8");
Logger.debug("utils", "Extracting zip entry", { fileName });
const processNext = (error?: NodeJS.ErrnoException | null) => {
if (error) {
zipfile.close();
reject(error);
return;
}
zipfile.readEntry();
};
if (validateFileName(fileName)) {
Logger.warn("Invalid zip entry", { fileName });
zipfile.readEntry();
} else if (/\/$/.test(fileName)) {
processNext();
return;
}
if (/\/$/.test(fileName)) {
// directory file names end with '/'
fs.mkdirp(
path.join(outputDir, fileName),
function (mErr: Error) {
if (mErr) {
return reject(mErr);
}
zipfile.readEntry();
}
fs.mkdirp(path.join(outputDir, fileName), (mkErr) =>
processNext(mkErr)
);
} else {
// file entry
zipfile.openReadStream(entry, function (rErr, readStream) {
if (rErr) {
return reject(rErr);
return processNext(rErr);
}
// ensure parent directory exists
fs.mkdirp(
path.join(outputDir, path.dirname(fileName)),
function (mkErr) {
if (mkErr) {
return reject(mkErr);
return processNext(mkErr);
}
const location = trimFileAndExt(
@@ -163,15 +178,20 @@ export default class ZipHelper {
);
const dest = fs
.createWriteStream(location)
.on("error", reject);
.on("error", (error) => {
readStream.destroy();
dest.destroy();
processNext(error);
});
readStream
.on("error", (rsErr) => {
dest.end();
reject(rsErr);
.on("error", (error) => {
dest.destroy();
readStream.destroy();
processNext(error);
})
.on("end", function () {
zipfile.readEntry();
processNext();
})
.pipe(dest);
}
@@ -180,8 +200,14 @@ export default class ZipHelper {
}
});
zipfile.on("close", resolve);
zipfile.on("error", reject);
zipfile.on("error", (error) => {
zipfile.close();
reject(error);
});
} catch (zErr) {
if (zipfile) {
zipfile.close();
}
reject(zErr);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Day } from "@shared/utils/time";
/**
* A Mock Helper class for server-side cache management
*/
export class CacheHelper {
// Default expiry time for cache data in seconds
private static defaultDataExpiry = Day.seconds;
/**
* Mocked method that resolves with the callback result
*/
public static async getDataOrSet<T>(
key: string,
callback: () => Promise<T | undefined>,
_expiry: number,
_lockTimeout: number
): Promise<T | undefined> {
return await callback();
}
/**
* Mocked method that resolves with undefined
*/
public static async getData<T>(_key: string): Promise<T | undefined> {
return undefined;
}
/**
* Mocked method that resolves with void
*/
public static async setData<T>(_key: string, _data: T, _expiry?: number) {
return;
}
/**
* Mocked method that resolves with void
*/
public static async clearData(_prefix: string) {
return;
}
/**
* These are real methods that don't require mocking as they don't
* interact with Redis directly
*/
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
public static getCollectionDocumentsKey(collectionId: string) {
return `cd:${collectionId}`;
}
}
+18
View File
@@ -0,0 +1,18 @@
export class MutexLock {
// Default expiry time for acquiring lock in milliseconds
public static defaultLockTimeout = 4000;
/**
* Returns the mock redlock instance
*/
public static get lock() {
return {
acquire: jest.fn().mockResolvedValue({
release: jest.fn().mockResolvedValue(true),
expiration: Date.now() + 10000,
}),
};
}
private static redlock: any;
}
+8
View File
@@ -56,4 +56,12 @@ describe("#ValidateKey.sanitize", () => {
ValidateKey.sanitize(`public/${uuid1}/${uuid2}/../../malicious_key`)
).toEqual(`public/${uuid1}/${uuid2}/malicious_key`);
});
it("should remove problematic characters", () => {
const uuid1 = uuidv4();
const uuid2 = uuidv4();
expect(ValidateKey.sanitize(`public/${uuid1}/${uuid2}/test#:*?`)).toEqual(
`public/${uuid1}/${uuid2}/test`
);
});
});
+22 -16
View File
@@ -26,7 +26,7 @@ export function assertArray(
message?: string
): asserts value {
if (!isArrayLike(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not an array`);
}
}
@@ -55,13 +55,11 @@ export function assertKeysIn(
Object.keys(obj).forEach((key) => assertIn(key, Object.values(type)));
}
export const assertSort = (
value: string,
model: any,
message = "Invalid sort parameter"
) => {
export const assertSort = (value: string, model: any, message?: string) => {
if (!Object.keys(model.rawAttributes).includes(value)) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a valid sort field`
);
}
};
@@ -72,7 +70,7 @@ export function assertNotEmpty(
assertPresent(value, message);
if (typeof value === "string" && value.trim() === "") {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is empty`);
}
}
@@ -81,7 +79,7 @@ export function assertEmail(
message?: string
): asserts value {
if (typeof value !== "string" || !validator.isEmail(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not a valid email`);
}
}
@@ -121,10 +119,12 @@ export function assertUuid(
message?: string
): asserts value {
if (typeof value !== "string") {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a string, expected UUID`
);
}
if (!validator.isUUID(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not a valid UUID`);
}
}
@@ -137,13 +137,17 @@ export const assertPositiveInteger = (
min: 0,
})
) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a positive integer`
);
}
};
export const assertHexColor = (value: string, message?: string) => {
if (!validateColorHex(value)) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not a valid hex color`
);
}
};
@@ -153,7 +157,9 @@ export const assertValueInArray = (
message?: string
) => {
if (!values.includes(value)) {
throw ValidationError(message);
throw ValidationError(
message ?? `${String(value)} is not in the allowed values`
);
}
};
@@ -162,7 +168,7 @@ export const assertIndexCharacters = (
message = "index must be between x20 to x7E ASCII"
) => {
if (!validateIndexCharacters(value)) {
throw ValidationError(message);
throw ValidationError(message ?? `${String(value)} is not a valid index`);
}
};
@@ -209,7 +215,7 @@ export class ValidateKey {
.slice(0, -1)
.filter((part) => part !== "" && part !== ".." && part !== ".")
.join("/")
.concat(`/${sanitize(filename)}`);
.concat(`/${sanitize(filename.replace(/#/g, ""))}`);
};
public static message = "Must be of the form <bucket>/<uuid>/<uuid>/<name>";
+2 -1
View File
@@ -55,7 +55,7 @@ const mathStyle = (props: Props) => css`
cursor: auto;
white-space: pre-wrap;
overflow-x: auto;
overflow-y: none;
overflow-y: hidden;
}
.math-node.empty-math .math-render::before {
@@ -708,6 +708,7 @@ img.ProseMirror-separator {
resize: none;
user-select: text;
margin: 0 auto !important;
width: 100%;
max-width: 100vw;
}
-49
View File
@@ -1,49 +0,0 @@
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import Extension from "../lib/Extension";
export default class Placeholder extends Extension {
get name() {
return "empty-placeholder";
}
get defaultOptions() {
return {
emptyNodeClass: "placeholder",
placeholder: "",
};
}
get plugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
const completelyEmpty =
doc.childCount <= 1 &&
doc.content.size <= 2 &&
doc.textContent === "";
if (completelyEmpty) {
doc.descendants((node, pos) => {
if (pos !== 0 || node.type.name !== "paragraph") {
return;
}
const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: this.options.emptyNodeClass,
"data-empty-text": this.options.placeholder,
});
decorations.push(decoration);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import FileExtension from "../components/FileExtension";
import { isRemoteTransaction } from "./multiplayer";
import { recreateTransform } from "./prosemirror-recreate-transform";
@@ -100,7 +100,8 @@ const uploadPlaceholder = new Plugin({
subtitle.className = "subtitle";
subtitle.innerText = "Uploading…";
ReactDOM.render(<FileExtension title={action.add.file.name} />, icon);
const root = createRoot(icon);
root.render(<FileExtension title={action.add.file.name} />);
element.appendChild(icon);
element.appendChild(title);

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