Compare commits

..

70 Commits

Author SHA1 Message Date
Tom Moor a7c61580f5 Require state parameter 2025-05-03 19:21:49 -04:00
Tom Moor 9c8dda0e0c final polish, use cryptographically secure random
Prevent redirect urls with http or wildcards
2025-05-03 16:00:41 -04:00
Tom Moor 40b7bea982 test: Add tests for oauthAuthentications endpoints 2025-05-03 15:13:29 -04:00
Tom Moor 1c184936f9 Cancel button should redirect to redirectURI when valid 2025-05-03 14:11:38 -04:00
Tom Moor f654b7e136 Merge branch 'main' into oauth-server 2025-05-03 12:30:46 -04:00
Tom Moor fb9021e9e1 fix: Allow saving OAuth app without description 2025-04-30 22:36:10 -04:00
Tom Moor 866eea1dcf tsc 2025-04-30 07:55:23 -04:00
Tom Moor eade6eb392 Merge branch 'main' into oauth-server 2025-04-29 23:04:01 -04:00
Tom Moor a4ee81fa19 OAuth server cloud compatibility (#9091)
* Revert "Add recency boost to search results (#9038)" (#9065)

This reverts commit 2bc47cfcef.

* fix: Double fetch on refactored paginated list (#9068)

* fix: Store Linear workspace logo only when it's available (#9072)

* Extract subdomain auth redirect (#9070)

* Extract subdomain auth redirect

* docs

* Switch Linear to actor=app method (#9074)

* Basic /authorize framework for cloud auth

* Handle logged out OAuth apex

---------

Co-authored-by: Hemachandar <132386067+hmacr@users.noreply.github.com>
2025-04-29 22:43:21 -04:00
Tom Moor 1330724c75 test 2025-04-26 18:00:52 -04:00
Tom Moor 5ca9fc9b44 Add processor to revoke external tokens when OAuthClient is unpublished 2025-04-26 17:25:04 -04:00
Tom Moor 4c7bc07b28 fix: Delete OAuth authentications when client is deleted 2025-04-26 17:14:43 -04:00
Tom Moor a033b08c83 tsc 2025-04-26 16:59:34 -04:00
Tom Moor f7d5d25247 OAuth token management screen (#8953) 2025-04-26 16:13:27 -04:00
Tom Moor 234e2d84ed Make token lifetimes configurable at installation level 2025-04-26 09:44:58 -04:00
Tom Moor 4389ac0d1d Merge main 2025-04-26 09:12:57 -04:00
Tom Moor f60f5fd66d Merge main 2025-04-22 21:29:53 -04:00
Tom Moor bee61ce1ef PR feedback 2025-04-22 21:19:16 -04:00
Tom Moor 20f5e953b7 Add PKCE parameters to /authorize 2025-04-15 21:20:08 -04:00
Tom Moor c2968a671c Add /oauth/revoke endpoint 2025-04-09 19:50:26 -04:00
Tom Moor 98959dc330 refactor: add OAuthClient.findByClientId 2025-04-08 09:16:55 -04:00
Tom Moor 7fc305b5d5 fix: Prevent OAuth tokens from creating webhooks 2025-04-07 20:47:29 -04:00
Tom Moor 1b90ab85e7 Add PKCE support 2025-04-07 20:29:43 -04:00
Tom Moor 4faf1b8570 fix: Guard authorization with unpublished client 2025-04-07 20:25:12 -04:00
Tom Moor c61bc5dedb fix: Cannot authorize with code grant 2025-04-07 20:23:42 -04:00
Tom Moor 3afde6962b Merge branch 'main' of github.com:outline/outline into oauth-server 2025-04-07 14:43:19 -04:00
Tom Moor cf7c97e9d6 Authentication middleware tests 2025-04-06 11:47:14 -04:00
Tom Moor 6298d7b31b PR feedback 2025-04-06 10:01:10 -04:00
Tom Moor 99ec9c1627 refactors 2025-04-06 09:58:33 -04:00
Tom Moor fdc53b91f8 Restore translations 2025-04-06 08:56:46 -04:00
Tom Moor eb3f74cf21 self review 2025-04-06 00:42:15 -04:00
Tom Moor 8656c21e14 lint 2025-04-06 00:26:43 -04:00
Tom Moor 24fd606a86 Improve validation 2025-04-06 00:16:42 -04:00
Tom Moor fe9a548490 Add OAuthClient icon to list, related Avatar refactor 2025-04-06 00:08:29 -04:00
Tom Moor f71afc2bf5 refactor 2025-04-05 23:12:14 -04:00
Tom Moor 9e7dd5b4f7 refactor 2025-04-05 22:42:34 -04:00
Tom Moor 021e431195 Add rotate secret functionality 2025-04-05 22:22:44 -04:00
Tom Moor 2136be9327 scope validation 2025-04-05 20:16:06 -04:00
Tom Moor 581502f7e2 Add rate limiting on endpoints 2025-04-05 19:05:43 -04:00
Tom Moor 75447cd782 scope formalization 2025-04-05 18:55:00 -04:00
Tom Moor fee3e7d0c3 Add additional read scopes 2025-04-05 18:05:49 -04:00
Tom Moor aed55c7cfd Readable scopes 2025-04-05 18:01:15 -04:00
Tom Moor 180d17e173 OAuth client edit flow 2025-04-05 17:49:29 -04:00
Tom Moor aa60f5ccea Bring error handling to spec for oauth endpoints 2025-04-05 09:51:39 -04:00
Tom Moor ea79883e04 Add constraints and indexes to migration 2025-04-05 00:14:56 -04:00
Tom Moor e9afc1d91f Add avatar upload 2025-04-04 23:40:28 -04:00
Tom Moor ed47b9eda0 fix authorize form 2025-04-04 23:14:41 -04:00
Tom Moor 5a19182757 Add menu, remove published option on self-hosted 2025-04-04 22:40:52 -04:00
Tom Moor 51b3971d21 New app modal 2025-04-04 22:06:47 -04:00
Tom Moor 057a1bbc7f Add developer to authorize page 2025-04-04 21:09:30 -04:00
Tom Moor 1289f5f3be Add tasks and processors 2025-04-04 19:01:54 -04:00
Tom Moor 429de07820 Add oauthClients endpoints 2025-04-04 18:47:07 -04:00
Tom Moor 68489973e0 Disable Authorize button on submit 2025-04-04 17:54:30 -04:00
Tom Moor 4dbd5b3617 fix: post-login redirect should include query string 2025-04-04 17:38:23 -04:00
Tom Moor f9fe1cc308 Merge main 2025-04-04 13:11:30 -04:00
Tom Moor fe03ba8710 scope-mapping 2025-04-04 13:10:27 -04:00
Tom Moor d13770ddf9 Authorize styling, scope conversion 2025-04-03 22:24:46 -04:00
Tom Moor b7425fefc6 Add cancel button 2025-04-03 21:53:03 -04:00
Tom Moor 79df4b030b Styling of Authorize page 2025-04-03 21:43:04 -04:00
Tom Moor 24eaeca47e Refactor out of plugin 2025-04-03 21:08:28 -04:00
Tom Moor db0deb6997 debugging 2025-04-02 09:45:56 -04:00
Tom Moor 9f21e57335 /authorize skeleton 2025-04-02 07:28:56 -04:00
Tom Moor fa6b83382b Add interface 2025-04-01 22:13:26 -04:00
Tom Moor b86475360f Move models to plugin 2025-04-01 07:38:30 -04:00
Tom Moor 9b179a2612 Change scope to array 2025-03-31 21:42:38 -04:00
Tom Moor 7afe69e22a Add token prefixes 2025-03-31 21:14:13 -04:00
Tom Moor 2ac19e3938 Add OAuthAuthentication 2025-03-31 21:04:01 -04:00
Tom Moor a25968d4a7 Add OAuthAuthorizationCode 2025-03-31 20:49:51 -04:00
Tom Moor 87b8e5daeb Add OAuthClient 2025-03-31 20:44:31 -04:00
Tom Moor 94a862ce01 Base migrations 2025-03-31 19:27:28 -04:00
153 changed files with 2803 additions and 6465 deletions
+140 -165
View File
@@ -1,80 +1,48 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
# 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
# The port to expose the Outline server on, this should match what is configured
# in your docker-compose.yml
# 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.
URL=
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" 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
# For "local", the avatar images and document attachments will be saved on local disk.
FILE_STORAGE=local
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images are stored. Make sure that the process has permissions to
# create this path and also to write files to it.
# which all attachments/images go. 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.
@@ -88,8 +56,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
@@ -99,55 +67,38 @@ AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––––– 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 ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– AUTHENTICATION ––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# or Microsoft is required for a working installation or you'll have no sign-in
# options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
# 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_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
# Google sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ
# 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_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Microsoft Entra / Azure AD sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/microsoft-entra-UVz6jsIOcv
# 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
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# 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
# 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
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
@@ -165,54 +116,83 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# ––––––––––––––––––––––––––––––––––––––
# –––––––––––––– 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
# 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
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The Linear integration allows previewing issue links as rich mentions
# Linear
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 in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
@@ -222,34 +202,29 @@ SLACK_MESSAGE_ACTIONS=true
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# DOCS: https://docs.getoutline.com/s/hosting/doc/sentry-jxcFttcDl5
# 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)
SENTRY_DSN=
SENTRY_TUNNEL=
# Enable importing pages from a Notion workspace
# DOCS: https://docs.getoutline.com/s/hosting/doc/notion-2v6g7WY3l3
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# 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=
# 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
# 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
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
@@ -1,59 +0,0 @@
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.84.0
Licensed Work: Outline 0.83.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-05-11
Change Date: 2029-04-11
Change License: Apache License, Version 2.0
+7 -31
View File
@@ -1,4 +1,3 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
@@ -15,15 +14,13 @@ 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";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -33,26 +30,6 @@ export interface FormData {
permission: CollectionPermission | undefined;
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
const hasMultipleCollections = collections.orderedData.length > 1;
const collectionColors = uniq(
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = React.useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
// otherwise pick a random color from the palette
(hasMultipleCollections && collectionColors.length === 1
? collectionColors[0]
: randomElement(colorPalette)),
[collection?.color]
);
return iconColor;
};
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
@@ -65,7 +42,11 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const iconColor = React.useMemo(
() => collection?.color ?? randomElement(colorPalette),
[collection?.color]
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
@@ -89,11 +70,6 @@ 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.
@@ -208,7 +184,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker.Component)`
const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
@@ -6,18 +6,15 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Text from "./Text";
const extensions = withUIExtensions(richExtensions);
@@ -25,8 +22,8 @@ type Props = {
collection: Collection;
};
function Overview({ collection }: Props) {
const { documents, collections } = useStores();
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
@@ -57,24 +54,6 @@ function Overview({ collection }: Props) {
[childOffsetHeight]
);
const onCreateLink = React.useCallback(
async (params: Properties<Document>) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
data: ProsemirrorHelper.getEmptyDocument(),
...params,
},
{
publish: true,
}
);
return newDocument.url;
},
[collection, documents]
);
return (
<>
{collections.isSaving && <LoadingIndicator />}
@@ -86,7 +65,6 @@ function Overview({ collection }: Props) {
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
@@ -105,4 +83,4 @@ const Placeholder = styled(Text)`
min-height: 27px;
`;
export default observer(Overview);
export default observer(CollectionDescription);
+10 -19
View File
@@ -138,7 +138,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
key={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={`${item.type}-${item.title}-${index}`}
key={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={`${item.type}-${item.title}-${index}`}
key={index}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
@@ -185,25 +185,18 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
<>{menuItem}</>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
return (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
key={index}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
@@ -216,17 +209,15 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
{...menu}
/>
) : null;
);
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
return <Separator key={index} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
return <Header key={index}>{item.title}</Header>;
}
const _exhaustiveCheck: never = item;
+1 -4
View File
@@ -64,12 +64,11 @@ 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;
}
@@ -81,8 +80,6 @@ function EditableTitle(
setValue(originalValue);
toast.error(error.message);
throw error;
} finally {
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit]
+1 -5
View File
@@ -45,7 +45,6 @@ type Props = {
onChange: (icon: string | null, color: string | null) => void;
onOpen?: () => void;
onClose?: () => void;
children?: React.ReactNode;
};
const IconPicker = ({
@@ -60,7 +59,6 @@ const IconPicker = ({
onOpen,
onClose,
borderOnHover,
children,
}: Props) => {
const { t } = useTranslation();
@@ -176,9 +174,7 @@ const IconPicker = ({
onClick={handlePopoverButtonClick}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
{iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
-47
View File
@@ -1,47 +0,0 @@
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,
};
}
+2 -4
View File
@@ -4,7 +4,6 @@ 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";
@@ -13,7 +12,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = createLazyComponent(
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
);
@@ -105,7 +104,6 @@ const ReactionPicker: React.FC<Props> = ({
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
@@ -125,7 +123,7 @@ const ReactionPicker: React.FC<Props> = ({
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel.Component
<EmojiPanel
height={300}
panelWidth={panelWidth}
query={query}
@@ -93,13 +93,11 @@ export const Suggestions = observer(
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = (
document
? users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id)
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
).filter((u) => !u.isSuspended);
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
+3 -12
View File
@@ -23,20 +23,12 @@ import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
function SettingsSidebar() {
const { ui, integrations } = useStores();
const { ui } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
"group"
);
const groupedConfig = groupBy(configs, "group");
const returnToApp = React.useCallback(() => {
history.push("/home");
@@ -71,9 +63,8 @@ function SettingsSidebar() {
<SidebarLink
key={item.path}
to={item.path}
onClickIntent={item.preload}
active={
item.path.startsWith(settingsPath("templates"))
item.path !== settingsPath()
? 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.allActive;
const orderedCollections = collections.orderedData;
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);
},
[]
+2 -14
View File
@@ -1,4 +1,3 @@
import * as Sentry from "@sentry/react";
import invariant from "invariant";
import find from "lodash/find";
import { action, observable } from "mobx";
@@ -135,15 +134,6 @@ 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) => {
@@ -261,10 +251,8 @@ class WebsocketProvider extends React.Component<Props> {
}
policies.remove(document.id);
if (event.collectionId) {
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
)
);
-1
View File
@@ -41,7 +41,6 @@ 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),
+25 -35
View File
@@ -1,12 +1,10 @@
import { action } from "mobx";
import { PlusIcon } from "outline-icons";
import { Node, ResolvedPos } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { createRoot } from "react-dom/client";
import ReactDOM from "react-dom";
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";
@@ -29,10 +27,7 @@ export default class BlockMenuExtension extends Suggestion {
const button = document.createElement("button");
button.className = "block-menu-trigger";
button.type = "button";
button.addEventListener("click", this.handleClick);
const root = createRoot(button);
root.render(<PlusIcon />);
return button;
ReactDOM.render(<PlusIcon />, button);
return [
...super.plugins,
@@ -54,6 +49,7 @@ 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(
@@ -73,39 +69,33 @@ 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}`,
},
]),
];
}
+2 -1
View File
@@ -18,7 +18,8 @@ export default function useEmbeds(loadIfMissing = false) {
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchAll({
await integrations.fetchPage({
limit: 100,
type: IntegrationType.Embed,
});
} catch (err) {
+25 -51
View File
@@ -13,21 +13,20 @@ 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 { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import ZapierIcon from "~/components/Icons/ZapierIcon";
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"));
@@ -44,38 +43,30 @@ 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,
preload: Profile.preload,
component: Profile,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
@@ -83,8 +74,7 @@ const useSettingsConfig = () => {
{
name: t("Preferences"),
path: settingsPath("preferences"),
component: Preferences.Component,
preload: Preferences.preload,
component: Preferences,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
@@ -92,8 +82,7 @@ const useSettingsConfig = () => {
{
name: t("Notifications"),
path: settingsPath("notifications"),
component: Notifications.Component,
preload: Notifications.preload,
component: Notifications,
enabled: true,
group: t("Account"),
icon: EmailIcon,
@@ -101,8 +90,7 @@ const useSettingsConfig = () => {
{
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps.Component,
preload: APIAndApps.preload,
component: APIAndApps,
enabled: true,
group: t("Account"),
icon: PadlockIcon,
@@ -111,8 +99,7 @@ const useSettingsConfig = () => {
{
name: t("Details"),
path: settingsPath("details"),
component: Details.Component,
preload: Details.preload,
component: Details,
enabled: can.update,
group: t("Workspace"),
icon: TeamIcon,
@@ -120,8 +107,7 @@ const useSettingsConfig = () => {
{
name: t("Security"),
path: settingsPath("security"),
component: Security.Component,
preload: Security.preload,
component: Security,
enabled: can.update,
group: t("Workspace"),
icon: PadlockIcon,
@@ -129,8 +115,7 @@ const useSettingsConfig = () => {
{
name: t("Features"),
path: settingsPath("features"),
component: Features.Component,
preload: Features.preload,
component: Features,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
@@ -138,8 +123,7 @@ const useSettingsConfig = () => {
{
name: t("Members"),
path: settingsPath("members"),
component: Members.Component,
preload: Members.preload,
component: Members,
enabled: can.listUsers,
group: t("Workspace"),
icon: UserIcon,
@@ -147,8 +131,7 @@ const useSettingsConfig = () => {
{
name: t("Groups"),
path: settingsPath("groups"),
component: Groups.Component,
preload: Groups.preload,
component: Groups,
enabled: can.listGroups,
group: t("Workspace"),
icon: GroupIcon,
@@ -156,8 +139,7 @@ const useSettingsConfig = () => {
{
name: t("Templates"),
path: settingsPath("templates"),
component: Templates.Component,
preload: Templates.preload,
component: Templates,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
@@ -165,8 +147,7 @@ const useSettingsConfig = () => {
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys.Component,
preload: ApiKeys.preload,
component: ApiKeys,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
@@ -174,8 +155,7 @@ const useSettingsConfig = () => {
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications.Component,
preload: Applications.preload,
component: Applications,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
@@ -183,8 +163,7 @@ const useSettingsConfig = () => {
{
name: t("Shared Links"),
path: settingsPath("shares"),
component: Shares.Component,
preload: Shares.preload,
component: Shares,
enabled: can.listShares,
group: t("Workspace"),
icon: GlobeIcon,
@@ -192,8 +171,7 @@ const useSettingsConfig = () => {
{
name: t("Import"),
path: settingsPath("import"),
component: Import.Component,
preload: Import.preload,
component: Import,
enabled: can.createImport,
group: t("Workspace"),
icon: ImportIcon,
@@ -201,20 +179,19 @@ const useSettingsConfig = () => {
{
name: t("Export"),
path: settingsPath("export"),
component: Export.Component,
preload: Export.preload,
component: Export,
enabled: can.createExport,
group: t("Workspace"),
icon: ExportIcon,
},
// Integrations
{
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
enabled: true,
name: "Zapier",
path: integrationSettingsPath("zapier"),
component: Zapier,
enabled: can.update && isCloudHosted,
group: t("Integrations"),
icon: PlusIcon,
icon: ZapierIcon,
},
];
@@ -231,10 +208,7 @@ const useSettingsConfig = () => {
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
pluginId: plugin.id,
description: plugin.value.description,
component: plugin.value.component.Component,
preload: plugin.value.component.preload,
component: plugin.value.component,
enabled: plugin.value.enabled
? plugin.value.enabled(team, user)
: can.update,
-88
View File
@@ -1,88 +0,0 @@
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;
}
+2 -3
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 { createRoot } from "react-dom/client";
import { render } from "react-dom";
import { HelmetProvider } from "react-helmet-async";
import { Router } from "react-router-dom";
import stores from "~/stores";
@@ -79,8 +79,7 @@ if (element) {
</React.StrictMode>
);
const root = createRoot(element);
root.render(<App />);
render(<App />, element);
}
window.addEventListener("load", async () => {
+1 -24
View File
@@ -2,13 +2,7 @@ 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,
ShapesIcon,
} from "outline-icons";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -63,7 +57,6 @@ 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";
@@ -83,7 +76,6 @@ 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 */
@@ -155,7 +147,6 @@ type MenuContentProps = {
onOpen?: () => void;
onClose?: () => void;
onFindAndReplace?: () => void;
onSelectTemplate?: (template: Document) => void;
onRename?: () => void;
showDisplayOptions?: boolean;
showToggleEmbeds?: boolean;
@@ -165,7 +156,6 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
onOpen,
onClose,
onFindAndReplace,
onSelectTemplate,
onRename,
showDisplayOptions,
showToggleEmbeds,
@@ -228,11 +218,6 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
[collections.orderedData, handleRestore, policies]
);
const templateMenuItems = useTemplateMenuItems({
document,
onSelectTemplate,
});
return !isEmpty(can) ? (
<ContextMenu
{...menuState}
@@ -325,12 +310,6 @@ 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),
{
@@ -404,7 +383,6 @@ function DocumentMenu({
modal = true,
showToggleEmbeds,
showDisplayOptions,
onSelectTemplate,
label,
onRename,
onOpen,
@@ -488,7 +466,6 @@ function DocumentMenu({
onOpen={onOpen}
onClose={onClose}
onRename={onRename}
onSelectTemplate={onSelectTemplate}
showDisplayOptions={showDisplayOptions}
showToggleEmbeds={showToggleEmbeds}
/>
+53 -4
View File
@@ -1,13 +1,17 @@
import { observer } from "mobx-react";
import { ShapesIcon } from "outline-icons";
import { DocumentIcon, 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 { useTemplateMenuItems } from "~/hooks/useTemplateMenuItems";
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 */
@@ -19,12 +23,57 @@ 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 items = useTemplateMenuItems({ onSelectTemplate, document });
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;
if (!items.length) {
return null;
+10 -8
View File
@@ -12,7 +12,7 @@ import {
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconTitleWrapper } from "@shared/components/Icon";
import Icon, { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
@@ -20,6 +20,7 @@ import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import CollectionDescription from "~/components/CollectionDescription";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearchPage from "~/components/InputSearchPage";
@@ -45,7 +46,6 @@ import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -139,7 +139,11 @@ const CollectionScene = observer(function _CollectionScene() {
const hasOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
<Icon
value={collection.icon ?? "collection"}
color={collection.color || undefined}
size={40}
/>
) : null;
const tabProps = (path: CollectionPath) => ({
@@ -158,7 +162,7 @@ const CollectionScene = observer(function _CollectionScene() {
left={
collection.isArchived ? (
<CollectionBreadcrumb collection={collection} />
) : (
) : collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
@@ -202,9 +206,7 @@ const CollectionScene = observer(function _CollectionScene() {
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
/>
</React.Suspense>
) : (
fallbackIcon
@@ -257,7 +259,7 @@ const CollectionScene = observer(function _CollectionScene() {
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<Overview collection={collection} />
<CollectionDescription collection={collection} />
) : (
<Redirect
to={{
+9 -19
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, TextSelection } from "prosemirror-state";
import { AllSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import {
@@ -146,17 +146,7 @@ class DocumentScene extends React.Component<Props> {
}
}
/**
* 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
) => {
replaceDocument = (template: Document | Revision) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -164,7 +154,6 @@ 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(
@@ -174,7 +163,11 @@ class DocumentScene extends React.Component<Props> {
);
if (doc) {
view.dispatch(view.state.tr.setSelection(sel).replaceSelectionWith(doc));
view.dispatch(
view.state.tr
.setSelection(new AllSelection(view.state.doc))
.replaceSelectionWith(doc)
);
}
this.isEditorDirty = true;
@@ -224,10 +217,7 @@ class DocumentScene extends React.Component<Props> {
});
if (response) {
await this.replaceSelection(
response.data,
new AllSelection(editorRef.view.state.doc)
);
await this.replaceDocument(response.data);
toast.success(t("Document restored"));
history.replace(this.props.document.url, history.location.state);
}
@@ -528,7 +518,7 @@ class DocumentScene extends React.Component<Props> {
}
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
onSelectTemplate={this.replaceSelection}
onSelectTemplate={this.replaceDocument}
onSave={this.onSave}
/>
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
@@ -387,7 +387,6 @@ function DocumentHeader({
neutral
/>
)}
onSelectTemplate={onSelectTemplate}
onFindAndReplace={editor?.commands.openFindAndReplace}
showToggleEmbeds={canToggleEmbeds}
showDisplayOptions
-4
View File
@@ -415,10 +415,6 @@ function KeyboardShortcuts() {
shortcut: <Key>---</Key>,
label: t("Horizontal divider"),
},
{
shortcut: <Key>{"|--"}</Key>,
label: t("Table"),
},
{
shortcut: <Key>{"```"}</Key>,
label: t("Code block"),
+3 -1
View File
@@ -67,7 +67,9 @@ function APIAndApps() {
/>
</Text>
) : (
<Trans>API keys have been disabled by an admin for your account</Trans>
<Trans>
{t("API keys have been disabled by an admin for your account")}
</Trans>
)}
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
-72
View File
@@ -1,72 +0,0 @@
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%";
`;
@@ -1,22 +1,20 @@
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 (
<IntegrationScene title="Zapier" icon={<ZapierIcon />}>
<Scene title="Zapier" icon={<ZapierIcon />}>
<Heading>Zapier</Heading>
<Helmet>
<script
@@ -41,15 +39,14 @@ function Zapier() {
<zapier-app-directory
app="outline"
link-target="new-tab"
sign-up-email={user.email}
theme={resolvedTheme === "system" ? undefined : resolvedTheme}
theme={resolvedTheme}
hide="notion,confluence-cloud,confluence,google-docs,slack"
applimit={6}
introcopy="hide"
create-without-template="show"
use-this-zap="show"
/>
</IntegrationScene>
</Scene>
);
}
@@ -1,93 +0,0 @@
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%;
}
`;
@@ -1,33 +0,0 @@
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>
);
}
+7 -2
View File
@@ -279,14 +279,19 @@ export default class DocumentsStore extends Store<Document> {
@action
fetchBacklinks = async (documentId: string): Promise<void> => {
const documents = await this.fetchAll({
const res = await client.post(`/documents.list`, {
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,
documents.map((doc) => doc.id)
data.map((doc: Partial<Document>) => doc.id)
);
});
};
-6
View File
@@ -10,12 +10,6 @@ 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,9 +104,6 @@ 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);
@@ -117,9 +114,6 @@ 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"
+2 -5
View File
@@ -3,7 +3,6 @@ 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";
@@ -28,10 +27,8 @@ type PluginValueMap = {
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The lazy loaded settings screen component. */
component: LazyComponent<React.ComponentType>;
/** The description that will show on the plugins card. */
description?: string;
/** The settings screen somponent, should be lazy loaded. */
component: React.LazyExoticComponent<React.ComponentType>;
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
+29 -32
View File
@@ -14,7 +14,6 @@
"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",
@@ -49,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@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.2",
"@babel/preset-react": "^7.27.1",
"@aws-sdk/client-s3": "3.797.0",
"@aws-sdk/lib-storage": "3.797.0",
"@aws-sdk/s3-presigned-post": "3.797.0",
"@aws-sdk/s3-request-presigner": "3.797.0",
"@aws-sdk/signature-v4-crt": "^3.796.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -87,15 +86,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.2",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.13.6",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.6",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
@@ -142,7 +141,7 @@
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
@@ -198,18 +197,18 @@
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
"react": "^18.2.0",
"react": "^17.0.2",
"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": "^18.2.0",
"react-dom": "^17.0.2",
"react-dropzone": "^11.7.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.14",
"react-medium-image-zoom": "5.2.13",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-router-dom": "^5.3.4",
@@ -229,7 +228,6 @@
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
"sequelize-strict-attributes": "^1.0.2",
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
@@ -250,7 +248,7 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.0",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^6.3.4",
"vite-plugin-pwa": "^0.21.2",
@@ -264,12 +262,11 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.2",
"@babel/preset-typescript": "^7.27.1",
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
"@types/crypto-js": "^4.2.2",
@@ -309,10 +306,10 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^18.2.0",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^18.2.0",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
@@ -331,7 +328,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.0",
"@types/validator": "^13.12.1",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
@@ -345,7 +342,7 @@
"discord-api-types": "^0.37.119",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-import-resolver-typescript": "^3.8.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -363,7 +360,7 @@
"nodemon": "^3.1.10",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"react-refresh": "^0.17.0",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.5",
"terser": "^5.39.0",
@@ -381,6 +378,6 @@
"qs": "6.9.7",
"prismjs": "1.30.0"
},
"version": "0.84.0",
"version": "0.83.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+53 -51
View File
@@ -1,5 +1,5 @@
import Router from "koa-router";
import { Client, NotificationEventType } from "@shared/types";
import { 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,64 +86,66 @@ router.post(
}
);
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;
router.get(
"email.callback",
validate(T.EmailCallbackSchema),
async (ctx: APIContext<T.EmailCallbackReq>) => {
const { token, client, follow } = ctx.input.query;
// 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");
}
// 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");
}
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,
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,
if (user.isInvited) {
await new WelcomeEmail({
to: user.email,
role: user.role,
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: client ?? Client.Web,
});
};
router.get("email.callback", validate(T.EmailCallbackSchema), emailCallback);
router.post("email.callback", validate(T.EmailCallbackSchema), emailCallback);
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,
});
}
);
export default router;
+1 -6
View File
@@ -13,12 +13,7 @@ export type EmailReq = z.infer<typeof EmailSchema>;
export const EmailCallbackSchema = BaseSchema.extend({
query: z.object({
token: z.string().optional(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
body: z.object({
token: z.string().optional(),
token: z.string(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
+3 -3
View File
@@ -4,7 +4,6 @@ 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";
@@ -12,6 +11,7 @@ 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 (
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
<Scene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
@@ -146,7 +146,7 @@ function GitHub() {
</Trans>
</Notice>
)}
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,9 +10,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
+9 -3
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,6 +40,12 @@ function GoogleAnalytics() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
@@ -69,7 +75,7 @@ function GoogleAnalytics() {
);
return (
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Heading>{t("Google Analytics")}</Heading>
<Text as="p" type="secondary">
@@ -94,7 +100,7 @@ function GoogleAnalytics() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</IntegrationScene>
</Scene>
);
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,9 +10,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "google-analytics",
"id": "googleanalytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
+2 -4
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,9 +10,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
+9 -3
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,6 +42,12 @@ function Matomo() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
measurementId: integration?.settings.measurementId,
@@ -76,7 +82,7 @@ function Matomo() {
);
return (
<IntegrationScene title="Matomo" icon={<Icon />}>
<Scene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
@@ -115,7 +121,7 @@ function Matomo() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</IntegrationScene>
</Scene>
);
}
+2 -4
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,9 +11,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
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")),
component: React.lazy(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+20 -20
View File
@@ -288,7 +288,7 @@ export class NotionConverter {
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text || item.mention.link_mention.href,
text: item.plain_text,
marks: [
{
type: "link",
@@ -302,7 +302,7 @@ export class NotionConverter {
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text || item.mention.link_preview.url,
text: item.plain_text,
marks: [
{
type: "link",
@@ -314,14 +314,14 @@ export class NotionConverter {
};
}
if (item.plain_text) {
return {
type: "text",
text: item.plain_text,
};
if (!item.plain_text) {
return undefined;
}
return undefined;
return {
type: "text",
text: item.plain_text,
};
}
if (item.type === "equation") {
@@ -336,20 +336,20 @@ export class NotionConverter {
};
}
if (item.text.content) {
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
if (!item.text.content) {
return undefined;
}
return undefined;
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
+7 -37
View File
@@ -1,5 +1,4 @@
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";
@@ -10,7 +9,6 @@ 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";
@@ -60,7 +58,7 @@ if (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number; id_token: string },
params: { expires_in: number },
_profile: unknown,
done: (
err: Error | null,
@@ -80,39 +78,14 @@ if (
accessToken
);
// 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) {
if (!profile.email) {
throw AuthenticationError(
`An email field was not returned in the profile or id_token parameter, but is required.`
`An email field was not returned in the profile parameter, but is required.`
);
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const { domain } = parseEmail(email);
const { domain } = parseEmail(profile.email);
// Only a single OIDC provider is supported find the existing, if any.
const authenticationProvider = team
@@ -145,16 +118,13 @@ if (
// Claim name can be overriden using an env variable.
// Default is 'preferred_username' as per OIDC spec.
// 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 username = get(profile, 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 ${env.OIDC_USERNAME_CLAIM}, name or username was returned in the profile parameter, but at least one is required.`
`Neither a name or username was returned in the profile parameter, but at least one is required.`
);
}
@@ -168,7 +138,7 @@ if (
},
user: {
name,
email,
email: profile.email,
avatarUrl: profile.picture,
},
authenticationProvider: {
+10 -4
View File
@@ -6,7 +6,6 @@ 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";
@@ -14,6 +13,7 @@ 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,7 +34,13 @@ function Slack() {
const error = query.get("error");
React.useEffect(() => {
void collections.fetchAll();
void collections.fetchPage({
limit: 100,
});
void integrations.fetchPage({
service: IntegrationService.Slack,
limit: 100,
});
}, [collections, integrations]);
const commandIntegration = integrations.find({
@@ -61,7 +67,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<IntegrationScene title="Slack" icon={<SlackIcon />}>
<Scene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -199,7 +205,7 @@ function Slack() {
</List>
</>
)}
</IntegrationScene>
</Scene>
);
}
+2 -4
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,9 +11,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
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")),
component: React.lazy(() => 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.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
authorize(user, "update", user.team);
+9 -3
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,6 +44,12 @@ function Umami() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
umamiWebsiteId: integration?.settings.measurementId,
@@ -79,7 +85,7 @@ function Umami() {
);
return (
<IntegrationScene title="Umami" icon={<Icon />}>
<Scene title="Umami" icon={<Icon />}>
<Heading>Umami</Heading>
<Text as="p" type="secondary">
@@ -139,7 +145,7 @@ function Umami() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</IntegrationScene>
</Scene>
);
}
+2 -4
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,9 +11,7 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
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")),
component: React.lazy(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+3 -6
View File
@@ -1,4 +1,4 @@
import { createLazyComponent } from "~/components/LazyLoad";
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -8,12 +8,9 @@ PluginManager.add([
...config,
type: Hook.Settings,
value: {
group: "Workspace",
after: "Shared Links",
group: "Integrations",
icon: Icon,
description:
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
component: createLazyComponent(() => import("./Settings")),
component: React.lazy(() => import("./Settings")),
},
},
]);
-18
View File
@@ -1,18 +0,0 @@
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
@@ -1,6 +0,0 @@
{
"id": "zapier",
"name": "Zapier",
"description": "Adds a settings screen for connecting to Zapier.",
"deployments": ["cloud"]
}
+30 -15
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -21,8 +22,8 @@ type Props = {
type Result = {
document: Document;
share: Share | null;
collection: Collection | null;
share?: Share;
collection?: Collection | null;
};
export default async function loadDocument({
@@ -32,9 +33,9 @@ export default async function loadDocument({
user,
includeState,
}: Props): Promise<Result> {
let document: Document | null = null;
let collection: Collection | null = null;
let share: Share | null = null;
let document;
let collection;
let share;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
@@ -71,7 +72,20 @@ export default async function loadDocument({
where: whereClause,
include: [
{
model: Document.scope("withDrafts"),
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
required: true,
as: "document",
},
@@ -115,11 +129,14 @@ export default async function loadDocument({
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
// Cannot use document.collection here as it does not include the
// documentStructure by default through the relationship.
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
rejectOnEmpty: true,
});
collection = await Collection.findByPk(document.collectionId);
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
}
return {
@@ -138,13 +155,11 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId, {
includeDocumentStructure: true,
rejectOnEmpty: true,
});
collection = await Collection.findByPk(document.collectionId);
}
invariant(collection, "collection not found");
if (!collection?.sharing) {
if (!collection.sharing) {
throw AuthorizationError();
}
+5 -6
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { createContext } from "@server/context";
import { traceFunction } from "@server/logging/tracing";
@@ -66,7 +67,6 @@ async function documentMover({
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.findByPk(document.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
@@ -76,7 +76,6 @@ async function documentMover({
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -145,12 +144,12 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
newCollection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
transaction,
});
invariant(newCollection, "Collection not found");
result.collections.push(newCollection);
+1 -6
View File
@@ -296,12 +296,7 @@ export default abstract class BaseEmail<
return undefined;
}
// Process user mentions to ensure they are uptodate with database
const processedNode = ProsemirrorHelper.toProsemirror(
await ProsemirrorHelper.processMentions(node)
);
let content = ProsemirrorHelper.toHTML(processedNode, {
let content = ProsemirrorHelper.toHTML(node, {
centered: false,
});
@@ -37,10 +37,9 @@ export default class CollectionCreatedEmail extends BaseEmail<
}
protected async beforeSend(props: InputProps) {
const collection = await Collection.findByPk(props.collectionId, {
includeOwner: true,
});
const collection = await Collection.scope("withUser").findByPk(
props.collectionId
);
if (!collection) {
return false;
}
+5 -46
View File
@@ -13,7 +13,6 @@ 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";
@@ -89,53 +88,13 @@ 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 (
/** 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"
) {
app.context.redirectOnClient = function (url: string) {
this.type = "text/html";
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 = `
this.body = `
<html>
<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>`;
}
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
};
// Add a health check endpoint to all services
@@ -174,7 +133,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);
}
+1 -3
View File
@@ -1,6 +1,5 @@
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";
@@ -38,7 +37,7 @@ export default function apexAuthRedirect<T>({
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
attributes: ["id", "domain", "subdomain"],
attributes: ["id", "subdomain"],
rejectOnEmpty: true,
});
@@ -46,7 +45,6 @@ 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
@@ -1,61 +0,0 @@
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);
};
}
@@ -1,29 +0,0 @@
"use strict";
const { execFileSync } = require("child_process");
const path = require("path");
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up() {
if (
process.env.NODE_ENV === "test" ||
process.env.DEPLOYMENT === "hosted"
) {
return;
}
const scriptName = path.basename(__filename);
const scriptPath = path.join(
process.cwd(),
"build",
`server/scripts/${scriptName}`
);
execFileSync("node", [scriptPath], { stdio: "inherit" });
},
async down() {
// noop
},
};
+29 -35
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
});
describe("#url", () => {
it("should return correct url for the collection", () => {
test("should return correct url for the collection", () => {
const collection = new Collection({
id: "1234",
});
@@ -25,7 +25,7 @@ describe("#url", () => {
});
describe("getDocumentParents", () => {
it("should return array of parent document ids", async () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
expect(result ? result[0] : undefined).toBe(parent.id);
});
it("should return array of parent document ids", async () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
expect(result?.length).toBe(0);
});
it("should not error if documentStructure is empty", async () => {
test("should not error if documentStructure is empty", async () => {
const parent = await buildDocument();
await buildDocument();
const collection = await buildCollection();
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
});
describe("getDocumentTree", () => {
it("should return document tree", async () => {
test("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [await document.toNavigationNode()],
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
);
});
it("should return nested documents in tree", async () => {
test("should return nested documents in tree", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
});
describe("#addDocumentToStructure", () => {
it("should add as last element without index", async () => {
test("should add as last element without index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure!.length).toBe(1);
});
it("should add with an index", async () => {
test("should add with an index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].id).toBe(id);
});
it("should add as a child if with parent", async () => {
test("should add as a child if with parent", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
it("should add as a child if with parent with index", async () => {
test("should add as a child if with parent with index", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
it("should add the document along with its nested document(s)", async () => {
test("should add the document along with its nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
);
});
it("should add the document along with its archived nested document(s)", async () => {
test("should add the document along with its archived nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
);
});
describe("options: documentJson", () => {
it("should append supplied json over document's own", async () => {
test("should append supplied json over document's own", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
});
describe("#updateDocument", () => {
it("should update root document's data", async () => {
test("should update root document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
expect(collection.documentStructure![0].title).toBe("Updated title");
});
it("should update child document's data", async () => {
test("should update child document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
newDocument.title = "Updated title";
await newDocument.save();
await collection.updateDocument(newDocument);
const reloaded = await collection.reload();
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
});
describe("#removeDocument", () => {
it("should save if removing", async () => {
test("should save if removing", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
expect(collection.save).toBeCalled();
});
it("should remove documents from root", async () => {
test("should remove documents from root", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
it("should remove a document with child documents", async () => {
test("should remove a document with child documents", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
it("should remove a child document", async () => {
test("should remove a child document", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(newDocument);
const reloaded = await collection.reload();
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded!.documentStructure!.length).toBe(1);
expect(reloaded!.documentStructure![0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
});
describe("#membershipUserIds", () => {
it("should return collection and group memberships", async () => {
test("should return collection and group memberships", async () => {
const team = await buildTeam();
const teamId = team.id;
// Make 6 users
@@ -464,53 +464,47 @@ describe("#membershipUserIds", () => {
});
describe("#findByPk", () => {
it("should return collection with collection Id", async () => {
test("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response!.id).toBe(collection.id);
});
it("should not return documentStructure by default", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(() => response!.documentStructure).toThrow();
});
it("should return collection when urlId is present", async () => {
test("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
it("should return collection when urlId is present, but missing slug", async () => {
test("should return collection when urlId is present, but missing slug", async () => {
const collection = await buildCollection();
const id = collection.urlId;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
it("should return null when incorrect uuid type", async () => {
test("should return null when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(null);
});
it("should return null when incorrect urlId length", async () => {
test("should return null when incorrect urlId length", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
});
it("should return null when no collection is found with uuid", async () => {
test("should return null when no collection is found with uuid", async () => {
const response = await Collection.findByPk(
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
);
expect(response).toBe(null);
});
it("should return null when no collection is found with urlId", async () => {
test("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
+10 -77
View File
@@ -14,8 +14,6 @@ import {
EmptyResultError,
type CreateOptions,
type UpdateOptions,
type ScopeOptions,
type SaveOptions,
} from "sequelize";
import {
Sequelize,
@@ -39,8 +37,6 @@ import {
AllowNull,
BeforeCreate,
BeforeUpdate,
DefaultScope,
AfterSave,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -50,7 +46,6 @@ 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";
@@ -71,17 +66,9 @@ import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
type AdditionalFindOptions = {
userId?: string;
includeDocumentStructure?: boolean;
includeOwner?: boolean;
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["documentStructure"],
},
}))
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -134,12 +121,6 @@ type AdditionalFindOptions = {
},
],
}),
withDocumentStructure: () => ({
attributes: {
// resets to include the documentStructure column
exclude: [],
},
}),
withMembership: (userId: string) => {
if (!userId) {
return {};
@@ -257,7 +238,6 @@ class Collection extends ParanoidModel<
@Column
maintainerApprovalRequired: boolean;
@Default(null)
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
@@ -337,34 +317,6 @@ 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
@@ -427,11 +379,8 @@ class Collection extends ParanoidModel<
model: Collection,
options: UpdateOptions<Collection>
) {
if (
(model.index && model.changed("index")) ||
(!model.archivedAt && model.changed("archivedAt"))
) {
model.index = await removeIndexCollision(model.teamId, model.index!, {
if (model.index && model.changed("index")) {
model.index = await removeIndexCollision(model.teamId, model.index, {
transaction: options.transaction,
});
}
@@ -504,9 +453,9 @@ class Collection extends ParanoidModel<
* @returns userIds
*/
static async membershipUserIds(collectionId: string) {
const collection = await this.scope("withAllMemberships").findOne({
where: { id: collectionId },
});
const collection = await this.scope("withAllMemberships").findByPk(
collectionId
);
if (!collection) {
return [];
}
@@ -523,7 +472,6 @@ 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
@@ -545,31 +493,16 @@ 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 scope.findOne({
const collection = await this.findOne({
where: {
id,
},
...rest,
...options,
rejectOnEmpty: false,
});
if (!collection && rest.rejectOnEmpty) {
if (!collection && options.rejectOnEmpty) {
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
}
@@ -578,7 +511,7 @@ class Collection extends ParanoidModel<
const match = id.match(UrlHelper.SLUG_URL_REGEX);
if (match) {
const collection = await scope.findOne({
const collection = await this.findOne({
where: {
urlId: match[1],
},
@@ -586,7 +519,7 @@ class Collection extends ParanoidModel<
rejectOnEmpty: false,
});
if (!collection && rest.rejectOnEmpty) {
if (!collection && options.rejectOnEmpty) {
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
}
+5 -2
View File
@@ -11,6 +11,7 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -95,8 +96,10 @@ describe("#delete", () => {
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
document.reload({ paranoid: false }),
collection.reload(),
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
+11 -35
View File
@@ -15,7 +15,6 @@ import {
FindOptions,
WhereOptions,
EmptyResultError,
Sequelize,
} from "sequelize";
import {
ForeignKey,
@@ -72,20 +71,12 @@ 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: [
{
@@ -110,14 +101,13 @@ type AdditionalFindOptions = {
},
},
attributes: {
include: [stateIfContentEmpty],
exclude: ["state"],
},
}))
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@Scopes(() => ({
withoutState: {
attributes: {
include: [stateIfContentEmpty],
exclude: ["state"],
},
},
withCollection: {
@@ -131,7 +121,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
include: [],
exclude: [],
},
},
withDrafts: {
@@ -172,13 +162,11 @@ type AdditionalFindOptions = {
return {
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
@@ -427,7 +415,6 @@ class Document extends ArchivableModel<
}
const collection = await Collection.findByPk(model.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -452,7 +439,6 @@ class Document extends ArchivableModel<
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.findByPk(model.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -646,13 +632,9 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
static withMembershipScope(
userId: string,
options?: FindOptions<Document> & { includeDrafts?: boolean }
) {
static withMembershipScope(userId: string, options?: FindOptions<Document>) {
return this.scope([
options?.includeDrafts ? "withDrafts" : "defaultScope",
"withoutState",
"defaultScope",
{
method: ["withViews", userId],
},
@@ -664,11 +646,10 @@ 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 document instance or null
* @returns A promise resolving to a collection instance or null
*/
static async findByPk(
id: Identifier,
@@ -693,7 +674,7 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
includeState ? "withState" : "withoutState",
options.includeState ? "withState" : "withoutState",
{
method: ["withViews", userId],
},
@@ -942,7 +923,6 @@ class Document extends ArchivableModel<
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1010,7 +990,6 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
})
@@ -1045,7 +1024,6 @@ class Document extends ArchivableModel<
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.findByPk(this.collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1070,7 +1048,6 @@ class Document extends ArchivableModel<
const { transaction } = { ...options };
const collection = collectionId
? await Collection.findByPk(collectionId, {
includeDocumentStructure: true,
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1123,7 +1100,6 @@ class Document extends ArchivableModel<
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
includeDocumentStructure: true,
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
+1 -179
View File
@@ -1,187 +1,9 @@
import { faker } from "@faker-js/faker";
import { DeepPartial } from "utility-types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { buildProseMirrorDoc } 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 = {
+1 -77
View File
@@ -21,7 +21,6 @@ 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 = {
@@ -491,7 +490,7 @@ export class ProsemirrorHelper {
// Render the Prosemirror document using virtual DOM and serialize the
// result to a string
const dom = new JSDOM(
`<!DOCTYPE html><meta charset="utf-8">${
`<!DOCTYPE html>${
options?.includeStyles === false ? "" : styleTags
}${html}`
);
@@ -559,79 +558,4 @@ 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);
}
}
+18 -16
View File
@@ -182,16 +182,16 @@ export default class SearchHelper {
},
];
return Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
return Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
}
public static async searchCollectionsForUser(
@@ -264,12 +264,14 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
results.length < limit && offset === 0
? Promise.resolve(results.length)
: countQuery,
+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.findByPk(collection.id, {
userId: admin.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", admin.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: member.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: member.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: member.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: member.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", member.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.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.findByPk(collection.id, {
userId: user.id,
});
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id);
const abilities = serialize(user, reloaded);
expect(abilities.read).toBeTruthy();
expect(abilities.readDocument).toBeTruthy();
+12 -18
View File
@@ -353,9 +353,9 @@ export default class WebsocketsProcessor {
case "collections.remove_user": {
const [collection, user] = await Promise.all([
Collection.findByPk(event.collectionId, {
userId: event.userId,
}),
Collection.scope({
method: ["withMembership", event.userId],
}).findByPk(event.collectionId),
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.findByPk(event.collectionId, {
userId: groupUser.userId,
}),
Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(event.collectionId),
User.findByPk(groupUser.userId),
]);
if (!user) {
@@ -716,12 +716,9 @@ export default class WebsocketsProcessor {
presentGroupMembership(groupMembership)
);
const collection = await Collection.findByPk(
groupMembership.collectionId,
{
userId: event.userId,
}
);
const collection = await Collection.scope({
method: ["withMembership", event.userId],
}).findByPk(groupMembership.collectionId);
if (cannot(user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
@@ -775,12 +772,9 @@ export default class WebsocketsProcessor {
.to(`user-${groupUser.userId}`)
.emit("collections.remove_group", payload);
const collection = await Collection.findByPk(
groupMembership.collectionId,
{
userId: groupUser.userId,
}
);
const collection = await Collection.scope({
method: ["withMembership", groupUser.userId],
}).findByPk(groupMembership.collectionId);
if (cannot(groupUser.user, "read", collection)) {
// tell any user clients to disconnect from the websocket channel for the collection
@@ -17,10 +17,8 @@ export default class CleanupDeletedDocumentsTask extends BaseTask<Props> {
"task",
`Permanently destroying upto ${limit} documents older than 30 days…`
);
const documents = await Document.scope([
"withDrafts",
"withoutState",
]).findAll({
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id", "teamId", "content", "text", "deletedAt"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
@@ -16,9 +16,9 @@ export default class CollectionSubscriptionRemoveUserTask extends BaseTask<Colle
return;
}
const collection = await Collection.findByPk(event.collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
Logger.debug(
@@ -171,8 +171,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
/**
* Generates a map of document urls to their path in the zip file.
*
* @param collections The collections to generate the path map for.
* @param format The format of the exported documents.
* @param collections
*/
private createPathMap(
collections: Collection[],
+5 -7
View File
@@ -44,13 +44,11 @@ export default abstract class ExportTask extends BaseTask<Props> {
? [fileOperation.collectionId]
: await user.collectionIds();
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where: {
id: collectionIds,
},
}
);
const collections = await Collection.findAll({
where: {
id: collectionIds,
},
});
let filePath: string | undefined;
@@ -1917,34 +1917,4 @@ 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");
});
});
+47 -51
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import Router from "koa-router";
import { Sequelize, Op, WhereOptions } from "sequelize";
import {
@@ -38,7 +39,6 @@ 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,11 +96,12 @@ router.post(
},
});
// we must reload the collection to get memberships for policy presenter
const reloaded = await Collection.findByPk(collection.id, {
userId: user.id,
const reloaded = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collection.id, {
transaction,
rejectOnEmpty: true,
});
invariant(reloaded, "collection not found");
ctx.body = {
data: await presentCollection(ctx, reloaded),
@@ -117,14 +118,11 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
"defaultScope",
"withArchivedBy",
{
method: ["withMembership", user.id],
},
]).findOne({
where: { id },
});
"withArchivedBy",
]).findByPk(id);
authorize(user, "read", collection);
@@ -142,27 +140,14 @@ router.post(
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(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: documentStructure || [],
data: collection.documentStructure || [],
};
}
);
@@ -214,7 +199,9 @@ router.post(
const { user } = ctx.state.auth;
const [collection, group] = await Promise.all([
Collection.findByPk(id, { userId: user.id, transaction }),
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction }),
Group.findByPk(groupId, { transaction }),
]);
authorize(user, "update", collection);
@@ -259,8 +246,9 @@ router.post(
const { transaction } = ctx.state;
const [collection, group] = await Promise.all([
Collection.findByPk(id, {
userId: user.id,
Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
}),
Group.findByPk(groupId, {
@@ -296,9 +284,9 @@ router.post(
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
let where: WhereOptions<GroupMembership> = {
@@ -366,7 +354,9 @@ router.post(
const { id, userId, permission } = ctx.input.body;
const [collection, user] = await Promise.all([
Collection.findByPk(id, { userId, transaction }),
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
@@ -410,7 +400,9 @@ router.post(
const { id, userId } = ctx.input.body;
const [collection, user] = await Promise.all([
Collection.findByPk(id, { userId, transaction }),
Collection.scope({
method: ["withMembership", actor.id],
}).findByPk(id, { transaction }),
User.findByPk(userId, { transaction }),
]);
authorize(actor, "update", collection);
@@ -441,9 +433,9 @@ router.post(
const { id, query, permission } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "read", collection);
let where: WhereOptions<UserMembership> = {
@@ -509,10 +501,9 @@ router.post(
const team = await Team.findByPk(user.teamId, { transaction });
authorize(user, "createExport", team);
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, { transaction });
authorize(user, "export", collection);
const fileOperation = await collectionExporter({
@@ -583,8 +574,9 @@ router.post(
} = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
});
authorize(user, "update", collection);
@@ -820,8 +812,9 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
});
@@ -850,8 +843,11 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id, {
transaction,
rejectOnEmpty: true,
});
@@ -907,11 +903,11 @@ router.post(
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id, {
transaction,
rejectOnEmpty: true,
});
authorize(user, "restore", collection);
+1 -3
View File
@@ -154,9 +154,7 @@ router.post(
]);
comments.forEach((comment) => (comment.document = document));
} else if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.findByPk(collectionId);
authorize(user, "read", collection);
const include = [
{
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: document.collectionId,
collection: document.collectionId,
},
});
const body = await res.json();
@@ -1013,7 +1013,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
collection: collection.id,
},
});
const body = await res.json();
+64 -59
View File
@@ -133,17 +133,15 @@ 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.findByPk(collectionId, {
userId: user.id,
includeDocumentStructure: sort === "index",
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = (collection.documentStructure || [])
documentIds = (collection?.documentStructure || [])
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where[Op.and].push({ id: documentIds });
@@ -329,9 +327,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.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
@@ -510,9 +508,9 @@ router.post(
const { user } = ctx.state.auth;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
}
@@ -537,14 +535,14 @@ router.post(
delete where.updatedAt;
}
const documents = await Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const documents = await Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(ctx, document))
);
@@ -819,20 +817,15 @@ router.post(
const destCollectionId = collectionId ?? sourceCollectionId;
const srcCollection = sourceCollectionId
? await Collection.findByPk(sourceCollectionId, {
userId: user.id,
includeDocumentStructure: true,
paranoid: false,
transaction,
})
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(sourceCollectionId, { paranoid: false })
: undefined;
const destCollection = destCollectionId
? await Collection.findByPk(destCollectionId, {
userId: user.id,
includeDocumentStructure: true,
transaction,
})
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(destCollectionId)
: undefined;
// In case of workspace templates, both source and destination collections are undefined.
@@ -934,9 +927,9 @@ router.post(
let collaboratorIds = undefined;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
}
@@ -1030,9 +1023,9 @@ router.post(
teamId = user.teamId;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
}
@@ -1121,10 +1114,9 @@ router.post(
authorize(user, "update", original);
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "createDocument", collection);
} else {
authorize(user, "createTemplate", user.team);
@@ -1209,10 +1201,9 @@ router.post(
collectionId,
"collectionId is required to publish a draft without collection"
);
collection = await Collection.findByPk(collectionId!, {
userId: user.id,
transaction,
});
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId!, { transaction });
}
if (document.parentDocumentId) {
@@ -1266,10 +1257,9 @@ router.post(
authorize(user, "read", document);
const collection = collectionId
? await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
})
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction })
: document?.collection;
if (collection) {
@@ -1329,10 +1319,9 @@ router.post(
authorize(user, "move", document);
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
@@ -1510,8 +1499,13 @@ router.post(
const file = ctx.input.file;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
},
});
authorize(user, "createDocument", collection);
let parentDocument;
@@ -1608,8 +1602,14 @@ router.post(
});
if (parentDocument?.collectionId) {
collection = await Collection.findByPk(parentDocument.collectionId, {
userId: user.id,
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: parentDocument.collectionId,
teamId: user.teamId,
},
transaction,
});
}
@@ -1617,8 +1617,13 @@ router.post(
collection,
});
} else if (collectionId) {
collection = await Collection.findByPk(collectionId, {
userId: user.id,
collection = await Collection.scope({
method: ["withMembership", user.id],
}).findOne({
where: {
id: collectionId,
teamId: user.teamId,
},
transaction,
});
authorize(user, "createDocument", collection);
+3 -3
View File
@@ -61,9 +61,9 @@ router.post(
if (collectionId) {
where = { ...where, collectionId };
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
} else {
const collectionIds = await user.collectionIds({
@@ -58,13 +58,13 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.withMembershipScope(userId, {
includeDrafts: true,
}).findAll({
where: {
id: documentIds,
},
});
const documents = await Document.withMembershipScope(userId)
.scope("withDrafts")
.findAll({
where: {
id: documentIds,
},
});
const groups = uniqBy(
memberships.map((membership) => membership.group),
+3 -4
View File
@@ -33,10 +33,9 @@ router.post(
authorize(user, "read", document);
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "update", collection);
authorize(user, "pin", document);
} else {
+1 -6
View File
@@ -54,12 +54,7 @@ router.post(
});
authorize(user, "read", document);
const collection = document.collectionId
? await Collection.findByPk(document.collectionId, {
userId: user.id,
includeDocumentStructure: true,
})
: undefined;
const collection = await document.$get("collection");
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({
+3 -4
View File
@@ -37,10 +37,9 @@ router.post(
}
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "star", collection);
}
@@ -34,10 +34,9 @@ router.post(
};
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
transaction: ctx.state.transaction,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
where.collectionId = collectionId;
@@ -79,9 +78,9 @@ router.post(
};
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
where.collectionId = collectionId;
@@ -117,9 +116,9 @@ router.post(
const { event, collectionId, documentId } = ctx.input.body;
if (collectionId) {
const collection = await Collection.findByPk(collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "subscribe", collection);
} else {
@@ -1,7 +1,6 @@
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";
@@ -29,7 +28,6 @@ router.post(
query,
offset,
limit,
statusFilter: [StatusFilter.Published],
}),
User.findAll({
where: {
+2
View File
@@ -260,6 +260,8 @@ 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");
}
@@ -1,127 +0,0 @@
import "./bootstrap";
import fractionalIndex from "fractional-index";
import { Sequelize, Transaction } from "sequelize";
import { Collection, Team } from "@server/models";
import { sequelize } from "@server/storage/database";
const limit = 100;
class CollectionIndexCollisionResolver {
private teamId: string;
private currDuplicateIndex: string | null = null;
private currDuplicateGroup: Collection[] = [];
private resolvedCollisionsCount: number = 0;
constructor(teamId: string) {
this.teamId = teamId;
}
public async process() {
await sequelize.transaction(async (transaction) => {
await this.processPage(0, transaction);
// edge case of last batch
await this.resolveDuplicates({ transaction });
});
}
private async processPage(
page: number,
transaction: Transaction
): Promise<void> {
console.log(
`Resolve collection index collisions for team ${this.teamId}… page ${page}`
);
const collections = await Collection.unscoped().findAll({
where: { teamId: this.teamId },
attributes: ["id", "index"],
limit,
offset: page * limit,
order: [
Sequelize.literal('"collection"."index" collate "C"'), // ensure duplicates are in sequential order
["updatedAt", "DESC"], // fallback as a tie breaker
],
lock: Transaction.LOCK.UPDATE,
transaction,
});
if (!collections.length) {
return;
}
let idx = 0;
while (idx < collections.length) {
const collection = collections[idx];
if (collection.index === this.currDuplicateIndex) {
// still in the same duplicate group.
this.currDuplicateGroup.push(collection);
} else {
// current collection index is different from the previous one; resolve duplicates, if applicable.
await this.resolveDuplicates({
nextCollection: collection,
transaction,
});
// reset the duplicate index and group.
this.currDuplicateIndex = collection.index;
this.currDuplicateGroup = [collection];
}
idx++;
}
return collections.length === limit
? this.processPage(page + 1, transaction)
: undefined;
}
private async resolveDuplicates({
nextCollection,
transaction,
}: {
nextCollection?: Collection;
transaction: Transaction;
}) {
if (this.currDuplicateGroup.length <= 1) {
// no action needed when there aren't more than 1 item in a group.
return;
}
let prevIndex = this.currDuplicateGroup[0].index;
const endIndex = nextCollection?.index ?? null;
// First collection in a duplicate group can retain its index.
for (let idx = 1; idx < this.currDuplicateGroup.length; idx++) {
const collection = this.currDuplicateGroup[idx];
const newIndex = fractionalIndex(prevIndex, endIndex);
console.log(`New index for collection ${collection.id} = ${newIndex}`);
collection.index = newIndex;
await collection.save({ silent: true, hooks: false, transaction });
prevIndex = newIndex;
}
this.resolvedCollisionsCount += this.currDuplicateGroup.length - 1;
}
}
export default async function main(exit = false) {
await Team.findAllInBatches<Team>({ batchLimit: 5 }, async (teams) => {
for (const team of teams) {
const resolver = new CollectionIndexCollisionResolver(team.id);
await resolver.process();
}
});
if (exit) {
process.exit(0);
}
}
// In the test suite we import the script rather than run via node CLI
if (process.env.NODE_ENV !== "test") {
void main(true);
}
+60 -7
View File
@@ -1,8 +1,13 @@
/* 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 { dnsPrefetchControl, referrerPolicy } from "koa-helmet";
import {
contentSecurityPolicy,
dnsPrefetchControl,
referrerPolicy,
} from "koa-helmet";
import mount from "koa-mount";
import enforceHttps, {
httpsResolver,
@@ -12,7 +17,6 @@ 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";
@@ -20,6 +24,32 @@ 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();
@@ -46,6 +76,10 @@ 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(() => {
@@ -62,10 +96,31 @@ export default function init(app: Koa = new Koa(), server?: Server) {
Metrics.gaugePerInstance("connections.count", 0);
});
app.use(mount("/api", api));
// 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");
// Apply CSP middleware after API as these responses are rendered in the browser
app.use(csp());
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);
});
// Allow DNS prefetching for performance, we do not care about leaking requests
// to our own CDN's
@@ -80,8 +135,6 @@ 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.findByPk(event.collectionId, {
userId: user.id,
});
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
await socket.join(`collection-${event.collectionId}`);
-47
View File
@@ -1,47 +0,0 @@
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 -4
View File
@@ -1,6 +1,5 @@
import path from "path";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import sequelizeStrictAttributes from "sequelize-strict-attributes";
import { Sequelize } from "sequelize-typescript";
import { Umzug, SequelizeStorage, MigrationError } from "umzug";
import env from "@server/env";
@@ -24,7 +23,7 @@ export function createDatabaseInstance(
}
): Sequelize {
try {
const instance = new Sequelize(databaseUrl, {
return new Sequelize(databaseUrl, {
logging: (msg) =>
process.env.DEBUG?.includes("database") &&
Logger.debug("database", msg),
@@ -48,8 +47,6 @@ export function createDatabaseInstance(
},
schema,
});
sequelizeStrictAttributes(instance);
return instance;
} catch (error) {
Logger.fatal(
"Could not connect to database",
+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 = 300;
public static defaultSignedUrlExpires = 60;
/**
* Returns a presigned post for uploading files to the storage provider.
+3 -28
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 { InternalError, ValidationError } from "@server/errors";
import { ValidationError } from "@server/errors";
import Logger from "@server/logging/Logger";
import BaseStorage from "./BaseStorage";
@@ -132,33 +132,8 @@ export default class LocalStorage extends BaseStorage {
};
}
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 getFileStream(key: string, range?: { start: number; end: number }) {
return Promise.resolve(fs.createReadStream(this.getFilePath(key), range));
}
public stat(key: string) {
+2 -4
View File
@@ -310,7 +310,7 @@ export async function buildCollection(
overrides.permission = CollectionPermission.ReadWrite;
}
return Collection.scope("withDocumentStructure").create({
return Collection.create({
name: faker.lorem.words(2),
description: faker.lorem.words(4),
createdById: overrides.userId,
@@ -416,9 +416,7 @@ export async function buildDocument(
if (overrides.collectionId && overrides.publishedAt !== null) {
collection = collection
? await Collection.findByPk(overrides.collectionId, {
includeDocumentStructure: true,
})
? await Collection.findByPk(overrides.collectionId)
: undefined;
await collection?.addDocumentToStructure(document, 0);

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