Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor 431937aa3a Add task to cleanup old events, change strategies to hourly 2025-02-15 20:57:16 -05:00
130 changed files with 1281 additions and 4074 deletions
+184
View File
@@ -0,0 +1,184 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
resource_class: large
environment:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
<<: *defaults
environment:
NODE_ENV: production
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
- run:
name: Send bundle stats to RelativeCI
command: npx relative-ci-agent
build-image:
resource_class: xlarge
executor: docker-publisher
steps:
- checkout
- setup_remote_docker
- run:
name: Install Docker buildx
command: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
- run:
name: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
- run:
name: Initialize Docker buildx
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
version: 2
all:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- build
- types
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
curl --user ${CIRCLE_TOKEN}: \
--request POST \
--form revision=<ENTER COMMIT SHA HERE>\
--form config=@config.yml \
--form notify=false \
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
-163
View File
@@ -1,163 +0,0 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_ENV: test
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
server:
- 'server/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
app:
- 'app/**'
- 'shared/**'
- 'package.json'
- 'yarn.lock'
test:
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14.2
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: outline_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
-52
View File
@@ -1,52 +0,0 @@
name: Docker build
on:
push:
tags:
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push base image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.1-3
Licensed Work: Outline 0.82.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-02-22
Change Date: 2029-02-15
Change License: Apache License, Version 2.0
-64
View File
@@ -8,10 +8,8 @@ import {
SearchIcon,
ShapesIcon,
StarredIcon,
SubscribeIcon,
TrashIcon,
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -207,66 +205,6 @@ export const unstarCollection = createAction({
},
});
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
@@ -393,7 +331,5 @@ export const rootCollectionActions = [
createCollection,
starCollection,
unstarCollection,
subscribeCollection,
unsubscribeCollection,
deleteCollection,
];
+2 -19
View File
@@ -125,20 +125,6 @@ export const createDocument = createAction({
}),
});
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
@@ -333,7 +319,6 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
@@ -362,9 +347,8 @@ export const unsubscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -1195,7 +1179,6 @@ export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
+1 -5
View File
@@ -1,4 +1,3 @@
import { getLuminance } from "polished";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
@@ -16,10 +15,7 @@ const Initials = styled(Flex)<{
border-radius: 50%;
width: 100%;
height: 100%;
color: ${(props) =>
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
? s("black50")
: s("white75")};
color: ${s("white75")};
background-color: ${(props) => props.color ?? props.theme.textTertiary};
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
+1 -2
View File
@@ -6,7 +6,6 @@ import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -62,7 +61,7 @@ function CollectionDescription({ collection }: Props) {
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={CollectionValidation.maxDescriptionLength}
maxLength={1000}
canUpdate={can.update}
readOnly={!can.update}
editorStyle={editorStyle}
+1 -10
View File
@@ -20,7 +20,6 @@ import {
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
@@ -168,7 +167,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
}
if (item.type === "button") {
const menuItem = (
return (
<MenuItem
as="button"
id={`${item.title}-${index}`}
@@ -183,14 +182,6 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip content={item.tooltip} placement={"bottom"}>
<div>{menuItem}</div>
</Tooltip>
) : (
<>{menuItem}</>
);
}
if (item.type === "submenu") {
+9 -8
View File
@@ -199,14 +199,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+77 -150
View File
@@ -7,9 +7,6 @@ import {
PublishIcon,
MoveIcon,
UnpublishIcon,
RestoreIcon,
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -17,61 +14,32 @@ import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
const EventListItem = ({ event, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: actor?.name,
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
@@ -82,32 +50,23 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = event.latest ? (
meta = latest ? (
<>
{t("Current version")} &middot; {actor?.name}
{t("Current version")} &middot; {event.actor.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: {
sidebarContext,
retainScrollPosition: true,
@@ -116,51 +75,47 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
break;
case "documents.archive":
icon = <ArchiveIcon />;
icon = <ArchiveIcon size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
icon = <RestoreIcon />;
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon />;
icon = <TrashIcon size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: user?.name ?? t("a user"),
addedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: user?.name ?? t("a user"),
removedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.restore":
icon = <RestoreIcon />;
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon />;
icon = <PublishIcon size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon />;
icon = <UnpublishIcon size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon />;
icon = <MoveIcon size={16} />;
meta = t("{{userName}} moved", opts);
break;
@@ -181,8 +136,8 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
to = undefined;
}
return event.name === "revisions.create" ? (
<RevisionItem
return (
<BaseItem
small
exact
to={to}
@@ -198,12 +153,17 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
onClick={handleTimeClick}
/>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision && isActive && !event.latest ? (
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
) : undefined
}
@@ -211,100 +171,63 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
</Text>
</EventItem>
);
};
const lineStyle = css`
&::before {
content: "";
display: block;
position: absolute;
top: -8px;
left: 22px;
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: multiply;
z-index: 1;
}
&:first-child::before {
display: none;
}
&:nth-child(2)::before {
display: none;
}
&::after {
content: "";
display: block;
position: absolute;
top: calc(50% + 14px);
left: 22px;
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: multiply;
z-index: 1;
}
&:last-child::after {
display: none;
}
h3 + &::before {
display: none;
}
`;
const IconWrapper = styled(Text)`
height: 24px;
`;
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 8px 0;
padding: 4px 10px;
white-space: nowrap;
position: relative;
time {
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
${lineStyle}
`;
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
return <ListItem to={to} ref={ref} {...rest} />;
});
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ItemStyle = css`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${s("textSecondary")};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.5;
@@ -315,4 +238,8 @@ const RevisionItem = styled(Item)`
}
`;
const ListItem = styled(Item)`
${ItemStyle}
`;
export default observer(EventListItem);
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
<Card>
<CardContent>
<Flex column>
+2 -12
View File
@@ -48,8 +48,7 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | void>;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
@@ -166,18 +165,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
if (previousValue.current === select.selectedValue) {
return;
}
const previous = previousValue.current;
previousValue.current = select.selectedValue;
const response = onChange?.(select.selectedValue);
if (response && response instanceof Promise) {
void response.then((success) => {
if (success === false) {
select.selectedValue = previous;
select.setSelectedValue(previous);
}
});
}
onChange?.(select.selectedValue);
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
+13 -5
View File
@@ -1,13 +1,16 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import PaginatedList from "~/components/PaginatedList";
import EventListItem, { type Event } from "./EventListItem";
import EventListItem from "./EventListItem";
type Props = {
events: Event[];
events: Event<Document>[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -29,8 +32,13 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+3 -8
View File
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
fetchCounter = 0;
@observable
renderCount = Pagination.defaultLimit;
renderCount = 15;
@observable
offset = 0;
@@ -108,16 +108,13 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
...this.props.options,
});
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
this.isFetchingInitial = false;
} catch (err) {
this.error = err;
@@ -251,9 +248,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
@@ -168,24 +167,18 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
if (permission === EmptySelectValue) {
await groupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
}
return true;
}}
disabled={!can.update}
value={membership.permission}
@@ -222,24 +215,18 @@ export const AccessControlList = observer(
| CollectionPermission
| typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
if (permission === EmptySelectValue) {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
} else {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
}
return true;
}}
disabled={!can.update}
value={membership.permission}
+26 -4
View File
@@ -1,25 +1,26 @@
import { observer } from "mobx-react";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { metaDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import { homePath, searchPath } from "~/utils/routeHelpers";
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
import TeamLogo from "../TeamLogo";
import Tooltip from "../Tooltip";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import { DraftsLink } from "./components/DraftsLink";
import DragPlaceholder from "./components/DragPlaceholder";
import HistoryNavigation from "./components/HistoryNavigation";
import Section from "./components/Section";
@@ -106,7 +107,24 @@ function AppSidebar() {
label={t("Search")}
exact={false}
/>
{can.createDocument && <DraftsLink />}
{can.createDocument && (
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
)}
</Section>
</Overflow>
<Scrollable flex shadow>
@@ -140,4 +158,8 @@ const Overflow = styled.div`
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);
@@ -14,7 +14,6 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
import { StyledError } from "./Collections";
import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative";
import SidebarContext from "./SidebarContext";
import SidebarLink from "./SidebarLink";
function ArchiveLink() {
@@ -65,40 +64,38 @@ function ArchiveLink() {
useDropToArchive();
return (
<SidebarContext.Provider value="archive">
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
<Flex column>
<div ref={dropToArchiveRef}>
<SidebarLink
to={archivePath()}
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
exact={false}
label={t("Archive")}
isActiveDrop={isOverArchiveSection && isDragging}
depth={0}
expanded={disclosure ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClick={handleClick}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</div>
{expanded === true ? (
<Relative>
<PaginatedList
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
collection={item}
/>
)}
/>
</Relative>
) : null}
</Flex>
</SidebarContext.Provider>
</Relative>
) : null}
</Flex>
);
}
@@ -1,41 +0,0 @@
import { observer } from "mobx-react";
import { DraftsIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { draftsPath } from "~/utils/routeHelpers";
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
import SidebarLink from "./SidebarLink";
export const DraftsLink = observer(() => {
const { t } = useTranslation();
const { documents } = useStores();
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
return (
<div ref={dropRef}>
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
isActiveDrop={isOver && canDrop}
/>
</div>
);
});
const Drafts = styled(Text)`
margin: 0 4px;
`;
@@ -5,7 +5,6 @@ import User from "~/models/User";
export type SidebarContextType =
| "collections"
| "shared"
| "archive"
| `group-${string}`
| `starred-${string}`
| undefined;
@@ -42,7 +41,7 @@ export const determineSidebarContext = ({
}
if (document.collection) {
return document.collection.isArchived ? "archive" : "collections";
return "collections";
} else if (
user.documentMemberships.find((m) => m.documentId === document.id)
) {
@@ -586,45 +586,3 @@ export function useDropToArchive() {
}),
});
}
export function useDropToUnpublish() {
const { t } = useTranslation();
const { policies, documents } = useStores();
return useDrop<
DragObject,
Promise<void>,
{ isOver: boolean; canDrop: boolean }
>({
accept: "document",
drop: async (item) => {
const document = documents.get(item.id);
if (!document) {
return;
}
try {
await document.unpublish({ detach: true });
toast.success(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
})
);
} catch (err) {
toast.error(err.message);
}
},
canDrop: (item) => {
const policy = policies.abilities(item.id);
if (!policy) {
return true; // optimistic, let the server check for the necessary permission.
}
return policy.unpublish;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
}
-26
View File
@@ -225,32 +225,6 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"documents.unpublish",
action(
(event: {
document: PartialExcept<Document, "id">;
collectionId: string;
}) => {
const document = event.document;
// When document is detached as part of unpublishing, only the owner should be able to view it.
if (
!document.collectionId &&
document.createdBy?.id !== currentUserId
) {
documents.remove(document.id);
} else {
documents.add(document);
}
policies.remove(document.id);
const collection = collections.get(event.collectionId);
collection?.removeDocument(document.id);
}
)
);
this.socket.on(
"documents.archive",
action((event: PartialExcept<Document, "id">) => {
+48 -61
View File
@@ -25,6 +25,52 @@ import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
@@ -215,12 +261,9 @@ export default class PasteHandler extends Extension {
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) &&
!isDropboxPaper(html) &&
!isContainingImage(html)) ||
(isMarkdown(text) && !isDropboxPaper(html)) ||
pasteCodeLanguage === "markdown" ||
this.shiftKey ||
!html
this.shiftKey
) {
event.preventDefault();
@@ -432,59 +475,3 @@ export default class PasteHandler extends Extension {
/>
);
}
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
/**
* Checks if the HTML string contains an image.
*
* @param html The HTML string to check.
* @returns True if the HTML string contains an image.
*/
function isContainingImage(html: string): boolean {
return html?.includes("<img");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
+5 -6
View File
@@ -2,14 +2,13 @@ import * as React from "react";
import usePersistedState from "~/hooks/usePersistedState";
import useStores from "./useStores";
type UrlId = "home" | string;
export const pinsCacheKey = (urlId: UrlId) => `pins-${urlId}`;
export function usePinnedDocuments(urlId: UrlId, collectionId?: string) {
export function usePinnedDocuments(
urlId: "home" | string,
collectionId?: string
) {
const { pins } = useStores();
const [pinsCacheCount, setPinsCacheCount] = usePersistedState<number>(
pinsCacheKey(urlId),
`pins-${urlId}`,
0
);
+1 -5
View File
@@ -8,8 +8,6 @@ type RequestResponse<T> = {
error: unknown;
/** Whether the request is currently in progress. */
loading: boolean;
/** Whether the request has completed - useful to check if the request has completed at least once. */
loaded: boolean;
/** Function to start the request. */
request: () => Promise<T | undefined>;
};
@@ -28,7 +26,6 @@ export default function useRequest<T = unknown>(
const isMounted = useIsMounted();
const [data, setData] = React.useState<T>();
const [loading, setLoading] = React.useState<boolean>(false);
const [loaded, setLoaded] = React.useState<boolean>(false);
const [error, setError] = React.useState();
const request = React.useCallback(async () => {
@@ -39,7 +36,6 @@ export default function useRequest<T = unknown>(
if (isMounted()) {
setData(response);
setError(undefined);
setLoaded(true);
}
return response;
} catch (err) {
@@ -61,5 +57,5 @@ export default function useRequest<T = unknown>(
}
}, [request, makeRequestOnMount]);
return { data, loading, loaded, error, request };
return { data, loading, error, request };
}
+3 -32
View File
@@ -14,7 +14,6 @@ import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { toast } from "sonner";
import { SubscriptionType } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import ContextMenu, { Placement } from "~/components/ContextMenu";
@@ -32,13 +31,10 @@ import {
createTemplate,
archiveCollection,
restoreCollection,
subscribeCollection,
unsubscribeCollection,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -67,28 +63,11 @@ function CollectionMenu({
placement,
});
const team = useCurrentTeam();
const { documents, dialogs, subscriptions } = useStores();
const { documents, dialogs } = useStores();
const { t } = useTranslation();
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const {
loading: subscriptionLoading,
loaded: subscriptionLoaded,
request: loadSubscription,
} = useRequest(() =>
subscriptions.fetchOne({
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const handlePointerEnter = React.useCallback(() => {
if (!subscriptionLoading && !subscriptionLoaded) {
void loadSubscription();
}
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
@@ -178,8 +157,6 @@ function CollectionMenu({
actionToMenuItem(restoreCollection, context),
actionToMenuItem(starCollection, context),
actionToMenuItem(unstarCollection, context),
actionToMenuItem(subscribeCollection, context),
actionToMenuItem(unsubscribeCollection, context),
{
type: "separator",
},
@@ -295,15 +272,9 @@ function CollectionMenu({
</label>
</VisuallyHidden>
{label ? (
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
{label}
</MenuButton>
<MenuButton {...menu}>{label}</MenuButton>
) : (
<OverflowMenuButton
aria-label={t("Show menu")}
{...menu}
onPointerEnter={handlePointerEnter}
/>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
)}
<ContextMenu
{...menu}
+14 -42
View File
@@ -1,6 +1,6 @@
import capitalize from "lodash/capitalize";
import isEmpty from "lodash/isEmpty";
import noop from "lodash/noop";
import isUndefined from "lodash/isUndefined";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -12,7 +12,7 @@ import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { SubscriptionType, UserPreference } from "@shared/types";
import { UserPreference } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
@@ -57,7 +57,7 @@ import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { MenuItem, MenuItemButton } from "~/types";
import { MenuItem } from "~/types";
import { documentEditPath } from "~/utils/routeHelpers";
import { MenuContext, useMenuContext } from "./MenuContext";
@@ -92,38 +92,22 @@ type MenuTriggerProps = {
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
const { t } = useTranslation();
const { subscriptions, pins } = useStores();
const { subscriptions } = useStores();
const { model: document, menuState } = useMenuContext<Document>();
const {
loading: auxDataLoading,
loaded: auxDataLoaded,
request: auxDataRequest,
} = useRequest(() =>
Promise.all([
subscriptions.fetchOne({
documentId: document.id,
event: SubscriptionType.Document,
}),
document.collectionId
? subscriptions.fetchOne({
collectionId: document.collectionId,
event: SubscriptionType.Document,
})
: noop,
pins.fetchOne({
documentId: document.id,
collectionId: document.collectionId ?? null,
}),
])
const { data, loading, error, request } = useRequest(() =>
subscriptions.fetchOne({
documentId: document.id,
event: "documents.update",
})
);
const handlePointerEnter = React.useCallback(() => {
if (!auxDataLoading && !auxDataLoaded) {
void auxDataRequest();
if (isUndefined(data ?? error) && !loading) {
void request();
void document.loadRelations();
}
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
}, [data, error, loading, request, document]);
return label ? (
<MenuButton
@@ -261,20 +245,8 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
},
actionToMenuItem(starDocument, context),
actionToMenuItem(unstarDocument, context),
{
...actionToMenuItem(subscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
{
...actionToMenuItem(unsubscribeDocument, context),
disabled: collection?.isSubscribed,
tooltip: collection?.isSubscribed
? t("Subscription inherited from collection")
: undefined,
} as MenuItemButton,
actionToMenuItem(subscribeDocument, context),
actionToMenuItem(unsubscribeDocument, context),
{
type: "button",
title: `${t("Find and replace")}`,
+5 -14
View File
@@ -3,7 +3,6 @@ import { TableOfContentsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
@@ -24,6 +23,7 @@ function TableOfContentsMenu() {
Infinity
);
// @ts-expect-error check
const items: MenuItem[] = React.useMemo(() => {
const i = [
{
@@ -34,20 +34,17 @@ function TableOfContentsMenu() {
...headings.map((heading) => ({
type: "link",
href: `#${heading.id}`,
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
title: t(heading.title),
level: heading.level - minHeading,
})),
] as MenuItem[];
];
if (i.length === 1) {
i.push({
type: "link",
href: "#",
title: (
<HeadingWrapper>
{t("Headings you add to the document will appear here")}
</HeadingWrapper>
),
title: t("Headings you add to the document will appear here"),
// @ts-expect-error check
disabled: true,
});
}
@@ -74,10 +71,4 @@ function TableOfContentsMenu() {
);
}
const HeadingWrapper = styled.div`
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
`;
export default observer(TableOfContentsMenu);
+2
View File
@@ -5,6 +5,8 @@ import Field from "./decorators/Field";
class AuthenticationProvider extends Model {
static modelName = "AuthenticationProvider";
id: string;
displayName: string;
name: string;
-26
View File
@@ -129,16 +129,6 @@ export default class Collection extends ParanoidModel {
);
}
/**
* Returns whether there is a subscription for this collection in the store.
*
* @returns True if there is a subscription, false otherwise.
*/
@computed
get isSubscribed(): boolean {
return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
}
@computed
get isManualSort(): boolean {
return this.sort.field === "index";
@@ -386,22 +376,6 @@ export default class Collection extends ParanoidModel {
@action
unstar = async () => this.store.unstar(this);
/**
* Subscribes the current user to this collection.
*
* @returns A promise that resolves when the subscription is created.
*/
@action
subscribe = () => this.store.subscribe(this);
/**
* Unsubscribes the current user from this collection.
*
* @returns A promise that resolves when the subscription is destroyed.
*/
@action
unsubscribe = () => this.store.unsubscribe(this);
archive = () => this.store.archive(this);
restore = () => this.store.restore(this);
+3 -13
View File
@@ -27,7 +27,6 @@ import { client } from "~/utils/ApiClient";
import { settingsPath } from "~/utils/routeHelpers";
import Collection from "./Collection";
import Notification from "./Notification";
import Pin from "./Pin";
import View from "./View";
import ArchivableModel from "./base/ArchivableModel";
import Field from "./decorators/Field";
@@ -449,11 +448,7 @@ export default class Document extends ArchivableModel implements Searchable {
restore = (options?: { revisionId?: string; collectionId?: string }) =>
this.store.restore(this, options);
unpublish = (
options: { detach?: boolean } = {
detach: false,
}
) => this.store.unpublish(this, options);
unpublish = () => this.store.unpublish(this);
@action
enableEmbeds = () => {
@@ -466,17 +461,12 @@ export default class Document extends ArchivableModel implements Searchable {
};
@action
pin = async (collectionId?: string | null) => {
const pin = new Pin({}, this.store.rootStore.pins);
await pin.save({
pin = (collectionId?: string | null) =>
this.store.rootStore.pins.create({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
return pin;
};
@action
unpin = (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
+2
View File
@@ -7,6 +7,8 @@ import Relation from "./decorators/Relation";
class Event<T extends Model> extends Model {
static modelName = "Event";
id: string;
name: string;
modelId: string | undefined;
+2
View File
@@ -11,6 +11,8 @@ import Model from "./base/Model";
class FileOperation extends Model {
static modelName = "FileOperation";
id: string;
@observable
state: FileOperationState;
+2
View File
@@ -12,6 +12,8 @@ import Relation from "~/models/decorators/Relation";
class Integration<T = unknown> extends Model {
static modelName = "Integration";
id: string;
type: IntegrationType;
service: IntegrationService;
+2
View File
@@ -9,6 +9,8 @@ import Relation from "./decorators/Relation";
class Membership extends Model {
static modelName = "Membership";
id: string;
userId: string;
@Relation(() => User, { onDelete: "cascade" })
+1 -31
View File
@@ -1,21 +1,15 @@
import { observable } from "mobx";
import PinsStore from "~/stores/PinsStore";
import { setPersistedState } from "~/hooks/usePersistedState";
import { pinsCacheKey } from "~/hooks/usePinnedDocuments";
import Collection from "./Collection";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterCreate, AfterDelete, AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Pin extends Model {
static modelName = "Pin";
store: PinsStore;
/** The collection ID that the document is pinned to. If empty the document is pinned to home. */
collectionId: string | null;
collectionId: string;
/** The collection that the document is pinned to. If empty the document is pinned to home. */
@Relation(() => Collection, { onDelete: "cascade" })
@@ -32,30 +26,6 @@ class Pin extends Model {
@observable
@Field
index: string;
@AfterCreate
@AfterDelete
@AfterRemove
static updateCache(model: Pin) {
const pins = model.store;
// Pinned to home
if (!model.collectionId) {
setPersistedState(pinsCacheKey("home"), pins.home.length);
return;
}
// Pinned to collection
const collection = pins.rootStore.collections.get(model.collectionId);
if (!collection) {
return;
}
setPersistedState(
pinsCacheKey(collection.urlId),
pins.inCollection(collection.id).length
);
}
}
export default Pin;
-8
View File
@@ -1,5 +1,4 @@
import { observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
@@ -26,13 +25,6 @@ class Subscription extends Model {
@Relation(() => Document, { onDelete: "cascade" })
document?: Document;
/** The collection ID being subscribed to */
collectionId: string;
/** The collection being subscribed to */
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/** The event being subscribed to */
@Field
@observable
+2
View File
@@ -7,6 +7,8 @@ import Relation from "./decorators/Relation";
class View extends Model {
static modelName = "View";
id: string;
documentId: string;
@Relation(() => Document)
+1 -4
View File
@@ -73,13 +73,10 @@ const CollectionScene = observer(function _CollectionScene() {
const sidebarContext = useLocationSidebarContext();
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
const { pins, count } = usePinnedDocuments(id, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
+2 -12
View File
@@ -183,7 +183,7 @@ function DataLoader({ match, children }: Props) {
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read && !document.isDeleted && !revisionId) {
if (can.read && !document.isDeleted) {
if (team.getPreference(TeamPreference.Commenting)) {
void comments.fetchAll({
documentId: document.id,
@@ -199,17 +199,7 @@ function DataLoader({ match, children }: Props) {
});
}
}
}, [
can.read,
can.update,
document,
isEditRoute,
comments,
team,
shares,
ui,
revisionId,
]);
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
if (error) {
return error instanceof OfflineError ? (
+36 -141
View File
@@ -1,16 +1,12 @@
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { Pagination } from "@shared/constants";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import EventModel from "~/models/Event";
import Revision from "~/models/Revision";
import Event from "~/models/Event";
import Empty from "~/components/Empty";
import { DocumentEvent, type Event } from "~/components/EventListItem";
import PaginatedEventList from "~/components/PaginatedEventList";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
@@ -18,148 +14,21 @@ import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
const DocumentEvents = [
"documents.publish",
"documents.unpublish",
"documents.archive",
"documents.unarchive",
"documents.delete",
"documents.restore",
"documents.add_user",
"documents.remove_user",
"documents.move",
];
const EMPTY_ARRAY: Event<Document>[] = [];
function History() {
const { events, documents, revisions } = useStores();
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.getByUrl(match.params.documentSlug);
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ revisions: 0, events: 0 }), []);
const eventsInDocument = document
? events.filter({ documentId: document.id })
: EMPTY_ARRAY;
const toEvent = React.useCallback(
(data: Revision | EventModel<Document>): Event => {
if (data instanceof Revision) {
return {
id: data.id,
name: "revisions.create",
actorId: data.createdBy.id,
createdAt: data.createdAt,
latest: false,
} satisfies Event;
}
return {
id: data.id,
name: data.name as DocumentEvent["name"],
actorId: data.actorId,
userId: data.userId,
createdAt: data.createdAt,
} satisfies Event;
},
[]
);
const fetchHistory = React.useCallback(async () => {
if (!document) {
return [];
}
const [revisionsArr, eventsArr] = await Promise.all([
revisions.fetchPage({
documentId: document.id,
offset: offset.revisions,
limit: Pagination.defaultLimit,
}),
events.fetchPage({
events: DocumentEvents,
documentId: document.id,
offset: offset.events,
limit: Pagination.defaultLimit,
}),
]);
const pageEvents = orderBy(
[...revisionsArr, ...eventsArr].map(toEvent),
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const revisionsCount = pageEvents.filter(
(event) => event.name === "revisions.create"
).length;
offset.revisions += revisionsCount;
offset.events += pageEvents.length - revisionsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageEvents;
}, [document, revisions, events, toEvent, offset]);
const revisionEvents = React.useMemo(() => {
if (!document) {
return [];
}
const latestRevisionId = RevisionHelper.latestId(document.id);
return revisions
.filter(
(revision: Revision) =>
revision.id !== latestRevisionId &&
revision.documentId === document.id
)
.slice(0, offset.revisions)
.map(toEvent);
}, [document, revisions, offset.revisions, toEvent]);
const nonRevisionEvents = React.useMemo(
() =>
document
? events
.filter({ documentId: document.id })
.slice(0, offset.events)
.map(toEvent)
: [],
[document, events, offset.events, toEvent]
);
const mergedEvents = React.useMemo(() => {
const merged = orderBy(
[...revisionEvents, ...nonRevisionEvents],
"createdAt",
"desc"
);
const latestEvent = merged[0];
if (latestEvent && document) {
const latestRevisionEvent = merged.find(
(event) => event.name === "revisions.create"
);
if (latestEvent.createdAt !== document.updatedAt) {
merged.unshift({
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
createdAt: document.updatedAt,
actorId: document.updatedBy?.id ?? "",
latest: true,
});
} else if (latestRevisionEvent) {
latestRevisionEvent.latest = true;
}
}
return merged;
}, [document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
const onCloseHistory = () => {
if (document) {
history.push({
pathname: documentPath(document),
@@ -168,7 +37,30 @@ function History() {
} else {
history.goBack();
}
}, [history, document, sidebarContext]);
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event(
{
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
},
events
)
);
}
return eventsInDocument;
}, [eventsInDocument, events, document]);
useKeyDown("Escape", onCloseHistory);
@@ -177,8 +69,11 @@ function History() {
{document ? (
<PaginatedEventList
aria-label={t("History")}
fetch={fetchHistory}
events={mergedEvents}
fetch={events.fetchPage}
events={items}
options={{
documentId: document.id,
}}
document={document}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
/>
-15
View File
@@ -8,7 +8,6 @@ import {
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
SubscriptionType,
} from "@shared/types";
import Collection from "~/models/Collection";
import { PaginationParams, Properties } from "~/types";
@@ -214,20 +213,6 @@ export default class CollectionsStore extends Store<Collection> {
await star?.delete();
};
subscribe = (collection: Collection) =>
this.rootStore.subscriptions.create({
collectionId: collection.id,
event: SubscriptionType.Document,
});
unsubscribe = (collection: Collection) => {
const subscription = this.rootStore.subscriptions.getByCollectionId(
collection.id
);
return subscription?.delete();
};
@computed
get navigationNodes() {
return this.orderedData.map((collection) => collection.asNavigationNode);
+21 -26
View File
@@ -5,12 +5,11 @@ import find from "lodash/find";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
import {
SubscriptionType,
type DateFilter,
type NavigationNode,
type PublicTeam,
type StatusFilter,
import type {
DateFilter,
NavigationNode,
PublicTeam,
StatusFilter,
} from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import { bytesToHumanReadable } from "@shared/utils/files";
@@ -64,7 +63,6 @@ export default class DocumentsStore extends Store<Document> {
".md",
".doc",
".docx",
".tsv",
"text/csv",
"text/markdown",
"text/plain",
@@ -344,8 +342,18 @@ export default class DocumentsStore extends Store<Document> {
};
@action
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("archived", options);
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
const archivedInResponse = await this.fetchNamedPage("archived", options);
const archivedInMemory = this.archived;
archivedInMemory.forEach((docInMemory) => {
!archivedInResponse.find(
(docInResponse) => docInResponse.id === docInMemory.id
) && this.remove(docInMemory.id);
});
return archivedInResponse;
};
@action
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
@@ -767,30 +775,17 @@ export default class DocumentsStore extends Store<Document> {
};
@action
unpublish = async (
document: Document,
options: { detach?: boolean } = {
detach: false,
}
) => {
unpublish = async (document: Document) => {
const res = await client.post("/documents.unpublish", {
id: document.id,
...options,
});
runInAction("Document#unpublish", () => {
invariant(res?.data, "Data should be available");
// unpublishing could sometimes detach the document from the collection.
// so, get the collection id before data is updated.
const collectionId = document.collectionId;
document.updateData(res.data);
this.addPolicies(res.policies);
if (collectionId) {
const collection = this.rootStore.collections.get(collectionId);
collection?.removeDocument(document.id);
}
const collection = this.getCollectionForDocument(document);
void collection?.fetchDocuments({ force: true });
});
};
@@ -818,7 +813,7 @@ export default class DocumentsStore extends Store<Document> {
subscribe = (document: Document) =>
this.rootStore.subscriptions.create({
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
});
unsubscribe = (document: Document) => {
-36
View File
@@ -3,7 +3,6 @@ import { action, runInAction, computed } from "mobx";
import Pin from "~/models/Pin";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -14,41 +13,6 @@ export default class PinsStore extends Store<Pin> {
super(rootStore, Pin);
}
@action
async fetchOne({
documentId,
collectionId,
}: {
documentId: string;
collectionId: string | null;
}) {
const pin = this.orderedData.find(
(p) => p.documentId === documentId && p.collectionId === collectionId
);
if (pin) {
return pin;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
documentId,
collectionId,
});
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
return;
}
throw err;
} finally {
this.isFetching = false;
}
}
@action
fetchPage = async (params?: FetchParams | undefined): Promise<Pin[]> => {
this.isFetching = true;
+1 -1
View File
@@ -58,7 +58,7 @@ export default class RevisionsStore extends Store<Revision> {
@action
fetchPage = async (
options: { documentId: string } & (PaginationParams | undefined)
options: PaginationParams | undefined
): Promise<Revision[]> => {
this.isFetching = true;
+6 -15
View File
@@ -1,6 +1,5 @@
import invariant from "invariant";
import { action } from "mobx";
import { SubscriptionType } from "@shared/types";
import Subscription from "~/models/Subscription";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
@@ -15,16 +14,8 @@ export default class SubscriptionsStore extends Store<Subscription> {
}
@action
async fetchOne(
options: { event: SubscriptionType } & (
| { documentId: string }
| { collectionId: string }
)
) {
const subscription =
"collectionId" in options
? this.getByCollectionId(options.collectionId)
: this.getByDocumentId(options.documentId);
async fetchOne({ documentId, event }: { documentId: string; event: string }) {
const subscription = this.getByDocumentId(documentId);
if (subscription) {
return subscription;
@@ -33,7 +24,10 @@ export default class SubscriptionsStore extends Store<Subscription> {
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, options);
const res = await client.post(`/${this.apiEndpoint}.info`, {
documentId,
event,
});
invariant(res?.data, "Data should be available");
return this.add(res.data);
} catch (err) {
@@ -48,7 +42,4 @@ export default class SubscriptionsStore extends Store<Subscription> {
getByDocumentId = (documentId: string): Subscription | undefined =>
this.find({ documentId });
getByCollectionId = (collectionId: string): Subscription | undefined =>
this.find({ collectionId });
}
-1
View File
@@ -27,7 +27,6 @@ export type MenuItemButton = {
selected?: boolean;
disabled?: boolean;
icon?: React.ReactNode;
tooltip?: React.ReactChild;
};
export type MenuItemWithChildren = {
+13 -13
View File
@@ -48,17 +48,17 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.749.0",
"@aws-sdk/lib-storage": "3.749.0",
"@aws-sdk/s3-presigned-post": "3.749.0",
"@aws-sdk/s3-request-presigner": "3.749.0",
"@aws-sdk/signature-v4-crt": "^3.749.0",
"@babel/core": "^7.26.9",
"@aws-sdk/client-s3": "3.744.0",
"@aws-sdk/lib-storage": "3.744.0",
"@aws-sdk/s3-presigned-post": "3.744.0",
"@aws-sdk/s3-request-presigner": "3.744.0",
"@aws-sdk/signature-v4-crt": "^3.744.0",
"@babel/core": "^7.26.7",
"@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.25.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-env": "^7.26.7",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
@@ -118,7 +118,7 @@
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"fetch-with-proxy": "^3.0.1",
"form-data": "^4.0.2",
"form-data": "^4.0.1",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^11.2.0",
@@ -201,7 +201,7 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.13",
"react-medium-image-zoom": "5.2.10",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
@@ -242,7 +242,7 @@
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.14",
"vite": "^5.4.12",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -264,7 +264,7 @@
"@types/crypto-js": "^4.2.2",
"@types/diff": "^5.0.9",
"@types/dotenv": "^8.2.3",
"@types/emoji-regex": "^9.2.2",
"@types/emoji-regex": "^9.2.0",
"@types/escape-html": "^1.0.4",
"@types/express-useragent": "^1.0.5",
"@types/formidable": "^2.0.6",
@@ -334,7 +334,7 @@
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -370,5 +370,5 @@
"qs": "6.9.7",
"rollup": "^4.5.1"
},
"version": "0.82.1-3"
"version": "0.82.0"
}
+3 -58
View File
@@ -4,15 +4,9 @@ import path from "path";
import FormData from "form-data";
import { ensureDirSync } from "fs-extra";
import { v4 as uuidV4 } from "uuid";
import { FileOperationState, FileOperationType } from "@shared/types";
import env from "@server/env";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
import FileStorage from "@server/storage/files";
import {
buildAttachment,
buildFileOperation,
buildUser,
} from "@server/test/factories";
import { buildAttachment, buildUser } from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
@@ -242,16 +236,7 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when file is requested using signature", async () => {
const user = await buildUser();
const fileName = "images.docx";
const { key } = await buildAttachment(
{
teamId: user.teamId,
userId: user.id,
contentType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
acl: "private",
},
fileName
);
const key = path.join("uploads", user.id, uuidV4(), fileName);
const signedUrl = await FileStorage.getSignedUrl(key);
ensureDirSync(
@@ -277,13 +262,6 @@ describe("#files.get", () => {
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
await buildAttachment({
key,
teamId: user.teamId,
userId: user.id,
contentType: "image/jpg",
acl: "public-read",
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
@@ -296,40 +274,7 @@ describe("#files.get", () => {
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Type")).toEqual("image/jpg");
expect(res.headers.get("Content-Type")).toEqual("application/octet-stream");
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
const user = await buildUser();
const fileName = "export-markdown.zip";
const key = `${Buckets.uploads}/${user.teamId}/${uuidV4()}/${fileName}`;
await buildFileOperation({
userId: user.id,
teamId: user.teamId,
type: FileOperationType.Export,
state: FileOperationState.Complete,
key,
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", fileName),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
const signedUrl = await FileStorage.getSignedUrl(key);
const url = new URL(signedUrl);
const res = await server.get(url.pathname + url.search);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Type")).toEqual("application/zip");
expect(res.headers.get("Content-Disposition")).toEqual(
'attachment; filename="export-markdown.zip"'
);
});
});
+10 -17
View File
@@ -5,7 +5,6 @@ import env from "@server/env";
import {
AuthenticationError,
AuthorizationError,
NotFoundError,
ValidationError,
} from "@server/errors";
import auth from "@server/middlewares/authentication";
@@ -78,25 +77,19 @@ router.get(
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
const attachment = await Attachment.findOne({
where: { key },
});
// Attachment is requested with a key, but it was not found
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
if (!skipAuthorize) {
authorize(actor, "read", attachment);
}
const contentType =
attachment?.contentType ||
let contentType =
(fileName ? mime.lookup(fileName) : undefined) ||
"application/octet-stream";
if (!skipAuthorize) {
const attachment = await Attachment.findOne({
where: { key },
rejectOnEmpty: true,
});
authorize(actor, "read", attachment);
contentType = attachment.contentType;
}
ctx.set("Accept-Ranges", "bytes");
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
Binary file not shown.
+6 -7
View File
@@ -62,13 +62,12 @@ export default async function pinCreator({
index = fractionalIndex(pins.length ? pins[0].index : null, null);
}
const [pin] = await Pin.findOrCreateWithCtx(ctx, {
where: {
collectionId: collectionId ?? null,
documentId,
teamId: user.teamId,
},
defaults: { index, createdById: user.id },
const pin = await Pin.createWithCtx(ctx, {
createdById: user.id,
teamId: user.teamId,
collectionId,
documentId,
index,
});
return pin;
+3 -41
View File
@@ -1,51 +1,14 @@
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import { Subscription, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import {
buildCollection,
buildDocument,
buildUser,
} from "@server/test/factories";
import { buildDocument, buildUser } from "@server/test/factories";
import subscriptionCreator from "./subscriptionCreator";
describe("subscriptionCreator", () => {
const ip = "127.0.0.1";
const subscribedEvent = SubscriptionType.Document;
const subscribedEvent = "documents.update";
it("should create a document subscription for the whole collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const subscription = await sequelize.transaction(async (transaction) =>
subscriptionCreator({
ctx: createContext({ user, transaction, ip }),
collectionId: collection.id,
event: SubscriptionType.Document,
})
);
const event = await Event.findOne({
where: {
teamId: user.teamId,
},
});
expect(subscription.collectionId).toEqual(collection.id);
expect(subscription.documentId).toBeNull();
expect(subscription.userId).toEqual(user.id);
expect(event?.name).toEqual("subscriptions.create");
expect(event?.modelId).toEqual(subscription.id);
expect(event?.actorId).toEqual(subscription.userId);
expect(event?.userId).toEqual(subscription.userId);
expect(event?.collectionId).toEqual(subscription.collectionId);
});
it("should create a document subscription", async () => {
it("should create a subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -68,7 +31,6 @@ describe("subscriptionCreator", () => {
});
expect(subscription.documentId).toEqual(document.id);
expect(subscription.collectionId).toBeNull();
expect(subscription.userId).toEqual(user.id);
expect(event?.name).toEqual("subscriptions.create");
expect(event?.modelId).toEqual(subscription.id);
+7 -22
View File
@@ -1,5 +1,3 @@
import { WhereOptions } from "sequelize";
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import { Subscription, Document } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -10,10 +8,8 @@ type Props = {
ctx: APIContext;
/** The document to subscribe to */
documentId?: string;
/** The collection to subscribe to */
collectionId?: string;
/** Event to subscribe to */
event: SubscriptionType;
event: string;
/** Whether the subscription should be restored if it exists in a deleted state */
resubscribe?: boolean;
};
@@ -26,27 +22,16 @@ type Props = {
export default async function subscriptionCreator({
ctx,
documentId,
collectionId,
event,
resubscribe = true,
}: Props): Promise<Subscription> {
const { user } = ctx.context.auth;
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
if (documentId) {
where.documentId = documentId;
}
if (collectionId) {
where.collectionId = collectionId;
}
const [subscription] = await Subscription.findOrCreateWithCtx(ctx, {
where,
where: {
userId: user.id,
documentId,
event,
},
paranoid: false, // Previous subscriptions are soft-deleted, we want to know about them here.
});
@@ -83,7 +68,7 @@ export const createSubscriptionsForDocument = async (
transaction,
}),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
resubscribe: false,
});
}
@@ -1,44 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"subscriptions",
"collectionId",
{
type: Sequelize.UUID,
allowNull: true,
onDelete: "cascade",
references: {
model: "collections",
},
},
{ transaction }
);
await queryInterface.addIndex(
"subscriptions",
["userId", "collectionId", "event"],
{
name: "subscriptions_user_id_collection_id_event",
type: "UNIQUE",
transaction,
}
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeIndex(
"subscriptions",
["userId", "collectionId", "event"],
{ transaction }
);
await queryInterface.removeColumn("subscriptions", "collectionId", {
transaction,
});
});
},
};
@@ -1,19 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface) {
await queryInterface.sequelize.query(
`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`,
);
await queryInterface.sequelize.query(
`CREATE INDEX CONCURRENTLY documents_title_idx ON documents USING GIN (title gin_trgm_ops);`,
);
},
async down (queryInterface) {
await queryInterface.sequelize.query(
`DROP INDEX CONCURRENTLY documents_title_idx;`,
);
}
};
@@ -1,27 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("teams", "previousSubdomains", {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
}, { transaction });
await queryInterface.sequelize.query(
`CREATE INDEX teams_previous_subdomains ON teams USING GIN ("previousSubdomains");`,
{ transaction }
);
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.sequelize.query(
`DROP INDEX teams_previous_subdomains;`,
{ transaction }
);
await queryInterface.removeColumn("teams", "previousSubdomains", { transaction });
});
},
};
-9
View File
@@ -46,15 +46,6 @@ describe("#ApiKey", () => {
});
describe("canAccess", () => {
it("should account for query string", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.info"],
});
expect(apiKey.canAccess("/api/documents.info?foo=bar")).toBe(true);
});
it("should return true for all resources if no scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
-3
View File
@@ -174,9 +174,6 @@ class ApiKey extends ParanoidModel<
return true;
}
// strip any query string from the path
path = path.split("?")[0];
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
+1 -12
View File
@@ -981,13 +981,7 @@ class Document extends ArchivableModel<
return false;
};
/**
*
* @param user User who is performing the action
* @param options.detach Whether to detach the document from the containing collection
* @returns Updated document
*/
unpublish = async (user: User, options: { detach: boolean }) => {
unpublish = async (user: User) => {
// If the document is already a draft then calling unpublish should act like save
if (!this.publishedAt) {
return this.save();
@@ -1016,11 +1010,6 @@ class Document extends ArchivableModel<
this.createdBy = user;
this.updatedBy = user;
this.publishedAt = null;
if (options.detach) {
this.collectionId = null;
}
return this.save();
};
-62
View File
@@ -19,17 +19,13 @@ import {
AfterCreate,
AfterUpdate,
AfterDestroy,
BeforeDestroy,
BeforeUpdate,
} from "sequelize-typescript";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { ValidationError } from "@server/errors";
import { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
import User from "./User";
import UserMembership from "./UserMembership";
import { type HookContext } from "./base/Model";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
@@ -251,36 +247,6 @@ class GroupMembership extends ParanoidModel<
}
}
@BeforeUpdate
static async checkLastAdminBeforeUpdate(
model: GroupMembership,
ctx: APIContext["context"]
) {
if (
model.permission === CollectionPermission.Admin ||
model.previous("permission") !== CollectionPermission.Admin ||
!model.collectionId
) {
return;
}
await this.validateLastAdminPermission(model, ctx);
}
@BeforeDestroy
static async checkLastAdminBeforeDestroy(
model: GroupMembership,
ctx: APIContext["context"]
) {
// Only check for last admin permission if this permission is admin
if (
model.permission !== CollectionPermission.Admin ||
!model.collectionId
) {
return;
}
await this.validateLastAdminPermission(model, ctx);
}
@AfterUpdate
static async publishAddGroupEventAfterUpdate(
model: GroupMembership,
@@ -400,34 +366,6 @@ class GroupMembership extends ParanoidModel<
await Document.insertEvent(name, this, hookContext);
}
}
private static async validateLastAdminPermission(
model: GroupMembership,
{ transaction }: APIContext["context"]
) {
const [userMemberships, groupMemberships] = await Promise.all([
UserMembership.count({
where: {
collectionId: model.collectionId,
permission: CollectionPermission.Admin,
},
transaction,
}),
this.count({
where: {
collectionId: model.collectionId,
permission: CollectionPermission.Admin,
},
transaction,
}),
]);
if (userMemberships === 0 && groupMemberships === 1) {
throw ValidationError(
"At least one user or group must have manage permissions"
);
}
}
}
export default GroupMembership;
+1 -10
View File
@@ -8,8 +8,6 @@ import {
IsIn,
Scopes,
} from "sequelize-typescript";
import { SubscriptionType } from "@shared/types";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
@@ -44,14 +42,7 @@ class Subscription extends ParanoidModel<
@Column(DataType.UUID)
documentId: string | null;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
collectionId: string | null;
@IsIn([Object.values(SubscriptionType)])
@IsIn([["documents.update"]])
@Column(DataType.STRING)
event: string;
}
+14 -37
View File
@@ -1,43 +1,20 @@
import { buildTeam, buildCollection } from "@server/test/factories";
describe("Team", () => {
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
});
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({
teamId: team.id,
permission: null,
});
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
describe("collectionIds", () => {
it("should return non-private collection ids", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
});
});
describe("previousSubdomains", () => {
it("should list the previous subdomains", async () => {
const team = await buildTeam({
subdomain: "example",
});
const subdomain = "updated";
await team.update({ subdomain });
expect(team.subdomain).toEqual(subdomain);
expect(team.previousSubdomains?.length).toEqual(1);
expect(team.previousSubdomains?.[0]).toEqual("example");
const subdomain2 = "another";
await team.update({ subdomain: subdomain2 });
expect(team.subdomain).toEqual(subdomain2);
expect(team.previousSubdomains?.length).toEqual(2);
expect(team.previousSubdomains?.[0]).toEqual("example");
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
// build a collection in another team
await buildCollection();
// build a private collection
await buildCollection({
teamId: team.id,
permission: null,
});
const response = await team.collectionIds();
expect(response.length).toEqual(1);
expect(response[0]).toEqual(collection.id);
});
});
-57
View File
@@ -171,9 +171,6 @@ class Team extends ParanoidModel<
@Column
lastActiveAt: Date | null;
@Column(DataType.ARRAY(DataType.STRING))
previousSubdomains: string[] | null;
// getters
/**
@@ -372,25 +369,6 @@ class Team extends ParanoidModel<
return model;
}
@BeforeUpdate
static async savePreviousSubdomain(model: Team) {
const previousSubdomain = model.previous("subdomain");
if (previousSubdomain && previousSubdomain !== model.subdomain) {
model.previousSubdomains = model.previousSubdomains || [];
if (!model.previousSubdomains.includes(previousSubdomain)) {
// Add the previous subdomain to the list of previous subdomains
// upto a maximum of 3 previous subdomains
model.previousSubdomains.push(previousSubdomain);
if (model.previousSubdomains.length > 3) {
model.previousSubdomains.shift();
}
}
}
return model;
}
@AfterUpdate
static deletePreviousAvatar = async (model: Team) => {
const previousAvatarUrl = model.previous("avatarUrl");
@@ -415,41 +393,6 @@ class Team extends ParanoidModel<
}
}
};
/**
* Find a team by its current or previous subdomain.
*
* @param subdomain - The subdomain to search for.
* @returns The team with the given or previous subdomain, or null if not found.
*/
static async findBySubdomain(subdomain: string) {
// Preference is always given to the team with the subdomain currently
// otherwise we can try and find a team that previously used the subdomain.
return (
(await this.findOne({
where: {
subdomain,
},
})) || (await this.findByPreviousSubdomain(subdomain))
);
}
/**
* Find a team by its previous subdomain.
*
* @param previousSubdomain - The previous subdomain to search for.
* @returns The team with the given previous subdomain, or null if not found.
*/
static async findByPreviousSubdomain(previousSubdomain: string) {
return this.findOne({
where: {
previousSubdomains: {
[Op.contains]: [previousSubdomain],
},
},
order: [["updatedAt", "DESC"]],
});
}
}
export default Team;
+1 -1
View File
@@ -1,7 +1,7 @@
import { buildAdmin, buildTeam } from "@server/test/factories";
import TeamDomain from "./TeamDomain";
describe("TeamDomain", () => {
describe("team domain model", () => {
describe("create", () => {
it("should allow creation of domains", async () => {
const team = await buildTeam();
-62
View File
@@ -19,15 +19,11 @@ import {
AfterUpdate,
Length,
AfterDestroy,
BeforeDestroy,
BeforeUpdate,
} from "sequelize-typescript";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { ValidationError } from "@server/errors";
import { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import GroupMembership from "./GroupMembership";
import User from "./User";
import IdModel from "./base/IdModel";
import { HookContext } from "./base/Model";
@@ -253,36 +249,6 @@ class UserMembership extends IdModel<
}
}
@BeforeUpdate
static async checkLastAdminBeforeUpdate(
model: UserMembership,
ctx: APIContext["context"]
) {
if (
model.permission === CollectionPermission.Admin ||
model.previous("permission") !== CollectionPermission.Admin ||
!model.collectionId
) {
return;
}
await this.validateLastAdminPermission(model, ctx);
}
@BeforeDestroy
static async checkLastAdminBeforeDestroy(
model: UserMembership,
ctx: APIContext["context"]
) {
// Only check for last admin permission if this permission is admin
if (
model.permission !== CollectionPermission.Admin ||
!model.collectionId
) {
return;
}
await this.validateLastAdminPermission(model, ctx);
}
@AfterUpdate
static async publishAddUserEventAfterUpdate(
model: UserMembership,
@@ -380,34 +346,6 @@ class UserMembership extends IdModel<
await Document.insertEvent(name, this, hookContext);
}
}
private static async validateLastAdminPermission(
model: UserMembership,
{ transaction }: APIContext["context"]
) {
const [userMemberships, groupMemberships] = await Promise.all([
this.count({
where: {
collectionId: model.collectionId,
permission: CollectionPermission.Admin,
},
transaction,
}),
GroupMembership.count({
where: {
collectionId: model.collectionId,
permission: CollectionPermission.Admin,
},
transaction,
}),
]);
if (userMemberships === 1 && groupMemberships === 0) {
throw ValidationError(
"At least one user or group must have manage permissions"
);
}
}
}
export default UserMembership;
@@ -1,152 +0,0 @@
import { NotificationEventType } from "@shared/types";
import {
buildDocument,
buildSubscription,
buildUser,
} from "@server/test/factories";
import NotificationHelper from "./NotificationHelper";
describe("NotificationHelper", () => {
describe("getDocumentNotificationRecipients", () => {
it("should return all users who have notification enabled for the event", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: false,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
it("should return users who have subscribed to the document", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const subscribedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(subscribedUser.id);
});
it("should return users who have subscribed to the collection", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const subscribedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(subscribedUser.id);
});
it("should return users who have subscribed to either the document or the containing collection", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const [documentSubscribedUser, collectionSubscribedUser] =
await Promise.all([
buildUser({
teamId: document.teamId,
}),
buildUser({
teamId: document.teamId,
}),
]);
await Promise.all([
buildSubscription({
userId: documentSubscribedUser.id,
documentId: document.id,
}),
buildSubscription({
userId: collectionSubscribedUser.id,
collectionId: document.collectionId!,
}),
]);
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(2);
const recipientIds = recipients.map((u) => u.id);
expect(recipientIds).toContain(collectionSubscribedUser.id);
expect(recipientIds).toContain(documentSubscribedUser.id);
});
it("should not return suspended users", async () => {
const documentAuthor = await buildUser();
const document = await buildDocument({
userId: documentAuthor.id,
teamId: documentAuthor.teamId,
});
const notificationEnabledUser = await buildUser({
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
});
// suspended user
await buildUser({
suspendedAt: new Date(),
teamId: document.teamId,
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: false,
actorId: documentAuthor.id,
});
expect(recipients.length).toEqual(1);
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
});
});
});
+17 -28
View File
@@ -1,10 +1,6 @@
import uniq from "lodash/uniq";
import { Op } from "sequelize";
import {
NotificationEventType,
MentionType,
SubscriptionType,
} from "@shared/types";
import { NotificationEventType, MentionType } from "@shared/types";
import Logger from "@server/logging/Logger";
import {
User,
@@ -60,12 +56,12 @@ export default class NotificationHelper {
comment: Comment,
actorId: string
): Promise<User[]> => {
let recipients = await this.getDocumentNotificationRecipients({
let recipients = await this.getDocumentNotificationRecipients(
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: !comment.parentCommentId,
NotificationEventType.UpdateDocument,
actorId,
});
!comment.parentCommentId
);
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(NotificationEventType.CreateComment)
@@ -131,22 +127,18 @@ export default class NotificationHelper {
* Get the recipients of a notification for a document event.
*
* @param document The document to get recipients for.
* @param notificationType The notification type for which to find the recipients.
* @param onlySubscribers Whether to consider only the users who have active subscription to the document.
* @param eventType The event name.
* @param actorId The id of the user that performed the action.
* @param onlySubscribers Whether to only return recipients that are actively
* subscribed to the document.
* @returns A list of recipients
*/
public static getDocumentNotificationRecipients = async ({
document,
notificationType,
onlySubscribers,
actorId,
}: {
document: Document;
notificationType: NotificationEventType;
onlySubscribers: boolean;
actorId: string;
}): Promise<User[]> => {
public static getDocumentNotificationRecipients = async (
document: Document,
eventType: NotificationEventType,
actorId: string,
onlySubscribers: boolean
): Promise<User[]> => {
// First find all the users that have notifications enabled for this event
// type at all and aren't the one that performed the action.
let recipients = await User.findAll({
@@ -159,7 +151,7 @@ export default class NotificationHelper {
});
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
recipient.subscribedToEventType(eventType)
);
// Filter further to only those that have a subscription to the document…
@@ -168,11 +160,8 @@ export default class NotificationHelper {
attributes: ["userId"],
where: {
userId: recipients.map((recipient) => recipient.id),
event: SubscriptionType.Document,
[Op.or]: [
{ collectionId: document.collectionId },
{ documentId: document.id },
],
documentId: document.id,
event: eventType,
},
});
+1 -1
View File
@@ -49,7 +49,7 @@ allow(User, "read", Collection, (user, collection) => {
allow(
User,
["readDocument", "star", "unstar", "subscribe", "unsubscribe"],
["readDocument", "star", "unstar"],
Collection,
(user, collection) => {
if (!collection || user.teamId !== collection.teamId) {
-1
View File
@@ -5,7 +5,6 @@ export default function presentSubscription(subscription: Subscription) {
id: subscription.id,
userId: subscription.userId,
documentId: subscription.documentId,
collectionId: subscription.collectionId,
event: subscription.event,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
@@ -42,6 +42,7 @@ export default class WebsocketsProcessor {
switch (event.name) {
case "documents.create":
case "documents.publish":
case "documents.unpublish":
case "documents.restore": {
const document = await Document.findByPk(event.documentId, {
paranoid: false,
@@ -72,28 +73,6 @@ export default class WebsocketsProcessor {
});
}
case "documents.unpublish": {
const document = await Document.findByPk(event.documentId, {
paranoid: false,
});
if (!document) {
return;
}
const documentToPresent = await presentDocument(undefined, document);
const channels = await this.getDocumentEventChannels(event, document);
// We need to add the collection channel to let the members update the doc structure.
channels.push(`collection-${event.collectionId}`);
return socketio.to(channels).emit(event.name, {
document: documentToPresent,
collectionId: event.collectionId,
});
}
case "documents.unarchive": {
const [document, srcCollection] = await Promise.all([
Document.findByPk(event.documentId, { paranoid: false }),
@@ -1,8 +1,4 @@
import {
MentionType,
NotificationEventType,
SubscriptionType,
} from "@shared/types";
import { MentionType, NotificationEventType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Comment, Document, Notification, User } from "@server/models";
@@ -38,7 +34,7 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
transaction,
}),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
resubscribe: false,
});
});
@@ -51,12 +51,12 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
await NotificationHelper.getDocumentNotificationRecipients(
document,
notificationType: NotificationEventType.PublishDocument,
onlySubscribers: false,
actorId: document.lastModifiedById,
})
NotificationEventType.PublishDocument,
document.lastModifiedById,
false
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
for (const recipient of recipients) {
@@ -1,5 +1,4 @@
import { Transaction } from "sequelize";
import { SubscriptionType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Subscription, User } from "@server/models";
@@ -35,7 +34,7 @@ export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent
transaction,
}),
documentId: event.documentId,
event: SubscriptionType.Document,
event: "documents.update",
resubscribe: false,
});
});
+1 -1
View File
@@ -176,7 +176,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
document.text = document.text
.replace(new RegExp(escapeRegExp(encodedPath), "g"), reference)
.replace(
new RegExp(`\\\.?/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
new RegExp(`\.?/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
reference
);
}
@@ -73,12 +73,12 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
await NotificationHelper.getDocumentNotificationRecipients(
document,
notificationType: NotificationEventType.UpdateDocument,
onlySubscribers: true,
actorId: document.lastModifiedById,
})
NotificationEventType.UpdateDocument,
document.lastModifiedById,
true
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
if (!recipients.length) {
return;
@@ -176,8 +176,6 @@ router.post(
throw InvalidRequestError(response.error);
}
await attachment.reload();
ctx.body = {
data: presentAttachment(attachment),
};
+6 -6
View File
@@ -1454,7 +1454,7 @@ router.post(
auth(),
validate(T.DocumentsUnpublishSchema),
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
const { id, detach } = ctx.input.body;
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, {
@@ -1473,14 +1473,14 @@ router.post(
);
}
// detaching would unset collectionId from document, so save a ref to the affected collectionId.
const collectionId = document.collectionId;
await document.unpublish(user, { detach });
await document.unpublish(user);
await Event.createFromContext(ctx, {
name: "documents.unpublish",
documentId: document.id,
collectionId,
collectionId: document.collectionId,
data: {
title: document.title,
},
});
ctx.body = {
-3
View File
@@ -300,9 +300,6 @@ export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
export const DocumentsUnpublishSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Whether to detach the document from the collection */
detach: z.boolean().default(false),
/** @deprecated Version of the API to be used, remove in a few releases */
apiVersion: z.number().optional(),
}),
-39
View File
@@ -228,45 +228,6 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(event.id);
});
it("should allow filtering by events param", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
collectionId: collection.id,
teamId: user.teamId,
});
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
events: ["documents.publish"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should return events with deleted actors", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
+16 -23
View File
@@ -1,5 +1,4 @@
import Router from "koa-router";
import intersection from "lodash/intersection";
import { Op, WhereOptions } from "sequelize";
import { EventHelper } from "@shared/utils/EventHelper";
import auth from "@server/middlewares/authentication";
@@ -21,35 +20,20 @@ router.post(
async (ctx: APIContext<T.EventsListReq>) => {
const { user } = ctx.state.auth;
const {
name,
events,
auditLog,
sort,
direction,
actorId,
documentId,
collectionId,
sort,
direction,
name,
auditLog,
} = ctx.input.body;
let where: WhereOptions<Event> = {
name: EventHelper.ACTIVITY_EVENTS,
teamId: user.teamId,
};
if (auditLog) {
authorize(user, "audit", user.team);
where.name = events
? intersection(EventHelper.AUDIT_EVENTS, events)
: EventHelper.AUDIT_EVENTS;
} else {
where.name = events
? intersection(EventHelper.ACTIVITY_EVENTS, events)
: EventHelper.ACTIVITY_EVENTS;
}
if (name && (where.name as string[]).includes(name)) {
where.name = name;
}
if (actorId) {
where = { ...where, actorId };
}
@@ -58,6 +42,15 @@ router.post(
where = { ...where, documentId };
}
if (auditLog) {
authorize(user, "audit", user.team);
where.name = EventHelper.AUDIT_EVENTS;
}
if (name && (where.name as string[]).includes(name)) {
where.name = name;
}
if (collectionId) {
where = { ...where, collectionId };
@@ -84,7 +77,7 @@ router.post(
};
}
const loadedEvents = await Event.findAll({
const events = await Event.findAll({
where,
order: [[sort, direction]],
include: [
@@ -101,7 +94,7 @@ router.post(
ctx.body = {
pagination: ctx.state.pagination,
data: await Promise.all(
loadedEvents.map((event) => presentEvent(event, auditLog))
events.map((event) => presentEvent(event, auditLog))
),
};
}
+1 -14
View File
@@ -1,19 +1,8 @@
import { z } from "zod";
import { EventHelper } from "@shared/utils/EventHelper";
import { BaseSchema } from "@server/routes/api/schema";
export const EventsListSchema = BaseSchema.extend({
body: z.object({
/** Events to retrieve */
events: z
.array(
z.union([
z.enum(EventHelper.ACTIVITY_EVENTS),
z.enum(EventHelper.AUDIT_EVENTS),
])
)
.optional(),
/** Id of the user who performed the action */
actorId: z.string().uuid().optional(),
@@ -26,9 +15,7 @@ export const EventsListSchema = BaseSchema.extend({
/** Whether to include audit events */
auditLog: z.boolean().default(false),
/** @deprecated, use 'events' parameter instead
* Name of the event to retrieve
*/
/** Name of the event to retrieve */
name: z.string().optional(),
/** The attribute to sort the events by */
-78
View File
@@ -168,84 +168,6 @@ describe("#pins.create", () => {
});
});
describe("#pins.info", () => {
it("should provide info about a home pin", async () => {
const admin = await buildAdmin();
const document = await buildDocument({
userId: admin.id,
teamId: admin.teamId,
});
await server.post("/api/pins.create", {
body: {
token: admin.getJwtToken(),
documentId: document.id,
},
});
const res = await server.post("/api/pins.info", {
body: {
token: admin.getJwtToken(),
documentId: document.id,
},
});
const pin = await res.json();
expect(res.status).toEqual(200);
expect(pin.data.id).toBeDefined();
expect(pin.data.documentId).toEqual(document.id);
expect(pin.data.collectionId).toBeFalsy();
});
it("should provide info about a collection pin", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/pins.create", {
body: {
token: user.getJwtToken(),
documentId: document.id,
collectionId: document.collectionId,
},
});
const res = await server.post("/api/pins.info", {
body: {
token: user.getJwtToken(),
documentId: document.id,
collectionId: document.collectionId,
},
});
const pin = await res.json();
expect(res.status).toEqual(200);
expect(pin.data.id).toBeDefined();
expect(pin.data.documentId).toEqual(document.id);
expect(pin.data.collectionId).toEqual(document.collectionId);
});
it("should throw 404 if no pin found", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/pins.info", {
body: {
token: user.getJwtToken(),
documentId: document.id,
collectionId: null,
},
});
expect(res.status).toEqual(404);
});
});
describe("#pins.list", () => {
let user: User;
let pins: Pin[];
+1 -30
View File
@@ -57,41 +57,12 @@ router.post(
}
);
router.post(
"pins.info",
auth(),
validate(T.PinsInfoSchema),
async (ctx: APIContext<T.PinsInfoReq>) => {
const { user } = ctx.state.auth;
const { documentId, collectionId } = ctx.input.body;
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, "read", document);
// There can be only one pin with these props.
const pin = await Pin.findOne({
where: {
documentId,
collectionId: collectionId ?? null,
createdById: user.id,
teamId: user.teamId,
},
rejectOnEmpty: true,
});
ctx.body = {
data: presentPin(pin),
policies: presentPolicies(user, [pin]),
};
}
);
router.post(
"pins.list",
auth(),
validate(T.PinsListSchema),
pagination(),
async (ctx: APIContext<T.PinsListReq>) => {
async (ctx: APIContext<T.PinsCreateReq>) => {
const { collectionId } = ctx.input.body;
const { user } = ctx.state.auth;
+1 -13
View File
@@ -1,7 +1,6 @@
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { zodIdType } from "@server/utils/zod";
import { BaseSchema } from "../schema";
export const PinsCreateSchema = BaseSchema.extend({
@@ -25,24 +24,13 @@ export const PinsCreateSchema = BaseSchema.extend({
export type PinsCreateReq = z.infer<typeof PinsCreateSchema>;
export const PinsInfoSchema = BaseSchema.extend({
body: z.object({
/** Document to get the pin info for. */
documentId: zodIdType(),
/** Collection to which the pin belongs to. If not set, it's considered as "Home" pin. */
collectionId: z.string().uuid().nullish(),
}),
});
export type PinsInfoReq = z.infer<typeof PinsInfoSchema>;
export const PinsListSchema = BaseSchema.extend({
body: z.object({
collectionId: z.string().uuid().nullish(),
}),
});
export type PinsListReq = z.infer<typeof PinsListSchema>;
export type PinsListReq = z.infer<typeof PinsCreateSchema>;
export const PinsUpdateSchema = BaseSchema.extend({
body: z.object({
-1
View File
@@ -125,7 +125,6 @@ router.post(
const document = await Document.findByPk(documentId, {
userId: user.id,
paranoid: false,
});
authorize(user, "listRevisions", document);
+18 -20
View File
@@ -1,38 +1,36 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { SubscriptionType } from "@shared/types";
import { ValidateDocumentId } from "@server/validation";
import { BaseSchema } from "../schema";
const SubscriptionBody = z
.object({
event: z.literal(SubscriptionType.Document),
collectionId: z.string().uuid().optional(),
documentId: z
.string()
.refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
})
.optional(),
})
.refine((obj) => !(isEmpty(obj.collectionId) && isEmpty(obj.documentId)), {
message: "one of collectionId or documentId is required",
});
export const SubscriptionsListSchema = BaseSchema.extend({
body: SubscriptionBody,
body: z.object({
documentId: z.string().refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
}),
event: z.literal("documents.update"),
}),
});
export type SubscriptionsListReq = z.infer<typeof SubscriptionsListSchema>;
export const SubscriptionsInfoSchema = BaseSchema.extend({
body: SubscriptionBody,
body: z.object({
documentId: z.string().refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
}),
event: z.literal("documents.update"),
}),
});
export type SubscriptionsInfoReq = z.infer<typeof SubscriptionsInfoSchema>;
export const SubscriptionsCreateSchema = BaseSchema.extend({
body: SubscriptionBody,
body: z.object({
documentId: z.string().refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
}),
event: z.literal("documents.update"),
}),
});
export type SubscriptionsCreateReq = z.infer<typeof SubscriptionsCreateSchema>;
@@ -1,41 +1,15 @@
import { SubscriptionType } from "@shared/types";
import { Event } from "@server/models";
import {
buildUser,
buildSubscription,
buildDocument,
buildCollection,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#subscriptions.create", () => {
it("should create a document subscription for the whole collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/subscriptions.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
event: SubscriptionType.Document,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeDefined();
expect(body.data.userId).toEqual(user.id);
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a document subscription", async () => {
it("should create a subscription", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -47,7 +21,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -71,7 +45,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -105,7 +79,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -114,7 +88,7 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -123,16 +97,17 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
// List subscriptions associated with `document.id`
// List subscriptions associated with
// `document.id`
const res = await server.post("/api/subscriptions.list", {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -157,7 +132,8 @@ describe("#subscriptions.create", () => {
body: {
token: user.getJwtToken(),
documentId: document.id,
// Subscription on event that cannot be subscribed to.
// Subscription on event
// that cannot be subscribed to.
event: "documents.publish",
},
});
@@ -171,62 +147,10 @@ describe("#subscriptions.create", () => {
`event: Invalid literal value, expected "documents.update"`
);
});
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
const user = await buildUser();
const res = await server.post("/api/subscriptions.create", {
body: {
token: user.getJwtToken(),
event: SubscriptionType.Document,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.ok).toEqual(false);
expect(body.error).toEqual("validation_error");
expect(body.message).toEqual(
"body: one of collectionId or documentId is required"
);
});
});
describe("#subscriptions.info", () => {
it("should provide info about a document subscription for the collection", async () => {
const user = await buildUser();
const subscriber = await buildUser({ teamId: user.teamId });
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/subscriptions.create", {
body: {
token: subscriber.getJwtToken(),
collectionId: collection.id,
event: SubscriptionType.Document,
},
});
const res = await server.post("/api/subscriptions.info", {
body: {
token: subscriber.getJwtToken(),
collectionId: collection.id,
event: SubscriptionType.Document,
},
});
const subscription = await res.json();
expect(res.status).toEqual(200);
expect(subscription.data.id).toBeDefined();
expect(subscription.data.userId).toEqual(subscriber.id);
expect(subscription.data.collectionId).toEqual(collection.id);
});
it("should provide info about a document subscription", async () => {
it("should provide info about a subscription", async () => {
const creator = await buildUser();
const subscriber = await buildUser({ teamId: creator.teamId });
@@ -247,7 +171,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -256,7 +180,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document1.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -266,7 +190,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -278,25 +202,6 @@ describe("#subscriptions.info", () => {
expect(response0.data.documentId).toEqual(document0.id);
});
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
const user = await buildUser();
const res = await server.post("/api/subscriptions.info", {
body: {
token: user.getJwtToken(),
event: SubscriptionType.Document,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.ok).toEqual(false);
expect(body.error).toEqual("validation_error");
expect(body.message).toEqual(
"body: one of collectionId or documentId is required"
);
});
it("should throw 404 if no subscription found", async () => {
const author = await buildUser();
const subscriber = await buildUser({ teamId: author.teamId });
@@ -309,7 +214,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -338,7 +243,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -347,16 +252,17 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document1.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
// `viewer` wants info about `subscriber`'s subscription on `document0`.
// `viewer` wants info about `subscriber`'s
// subscription on `document0`.
const subscription0 = await server.post("/api/subscriptions.info", {
body: {
token: viewer.getJwtToken(),
documentId: document0.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -368,12 +274,13 @@ describe("#subscriptions.info", () => {
expect(response0.error).toEqual("authorization_error");
expect(response0.message).toEqual("Authorization error");
// `viewer` wants info about `subscriber`'s subscription on `document0`.
// `viewer` wants info about `subscriber`'s
// subscription on `document0`.
const subscription1 = await server.post("/api/subscriptions.info", {
body: {
token: viewer.getJwtToken(),
documentId: document1.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -409,7 +316,7 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document0.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -418,11 +325,13 @@ describe("#subscriptions.info", () => {
body: {
token: subscriber.getJwtToken(),
documentId: document1.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
// `viewer` wants info about `subscriber`'s subscription on `document0` - they have requested an invalid event.
// `viewer` wants info about `subscriber`'s
// subscription on `document0`.
// They have requested an invalid event.
const subscription0 = await server.post("/api/subscriptions.info", {
body: {
token: viewer.getJwtToken(),
@@ -463,7 +372,7 @@ describe("#subscriptions.info", () => {
});
describe("#subscriptions.list", () => {
it("should list user subscriptions for the document", async () => {
it("should list user subscriptions", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -471,16 +380,19 @@ describe("#subscriptions.list", () => {
teamId: user.teamId,
});
await buildSubscription();
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
event: "documents.update",
});
const res = await server.post("/api/subscriptions.list", {
body: {
token: user.getJwtToken(),
documentId: document.id,
event: SubscriptionType.Document,
event: "documents.update",
},
});
@@ -682,25 +594,6 @@ describe("#subscriptions.list", () => {
expect(body.error).toEqual("authorization_error");
expect(body.message).toEqual("Authorization error");
});
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
const user = await buildUser();
const res = await server.post("/api/subscriptions.list", {
body: {
token: user.getJwtToken(),
event: SubscriptionType.Document,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.ok).toEqual(false);
expect(body.error).toEqual("validation_error");
expect(body.message).toEqual(
"body: one of collectionId or documentId is required"
);
});
});
describe("#subscriptions.delete", () => {
@@ -715,6 +608,7 @@ describe("#subscriptions.delete", () => {
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
event: "documents.update",
});
const res = await server.post("/api/subscriptions.delete", {
@@ -743,6 +637,7 @@ describe("#subscriptions.delete", () => {
const subscription = await buildSubscription({
userId: user.id,
documentId: document.id,
event: "documents.update",
});
const res = await server.post("/api/subscriptions.delete", {
@@ -1,5 +1,5 @@
import Router from "koa-router";
import { Transaction, WhereOptions } from "sequelize";
import { Transaction } from "sequelize";
import { QueryNotices } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
@@ -8,7 +8,7 @@ import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Subscription, Document, User, Collection } from "@server/models";
import { Subscription, Document, User } from "@server/models";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
import { authorize } from "@server/policies";
import { presentSubscription } from "@server/presenters";
@@ -26,32 +26,18 @@ router.post(
validate(T.SubscriptionsListSchema),
async (ctx: APIContext<T.SubscriptionsListReq>) => {
const { user } = ctx.state.auth;
const { event, collectionId, documentId } = ctx.input.body;
const { documentId, event } = ctx.input.body;
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
const document = await Document.findByPk(documentId, { userId: user.id });
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
where.collectionId = collectionId;
} else {
// documentId will be available here
const document = await Document.findByPk(documentId!, {
userId: user.id,
});
authorize(user, "read", document);
where.documentId = documentId;
}
authorize(user, "read", document);
const subscriptions = await Subscription.findAll({
where,
where: {
documentId: document.id,
userId: user.id,
event,
},
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
@@ -70,33 +56,19 @@ router.post(
validate(T.SubscriptionsInfoSchema),
async (ctx: APIContext<T.SubscriptionsInfoReq>) => {
const { user } = ctx.state.auth;
const { event, collectionId, documentId } = ctx.input.body;
const { documentId, event } = ctx.input.body;
const where: WhereOptions<Subscription> = {
userId: user.id,
event,
};
const document = await Document.findByPk(documentId, { userId: user.id });
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
where.collectionId = collectionId;
} else {
// documentId will be available here
const document = await Document.findByPk(documentId!, {
userId: user.id,
});
authorize(user, "read", document);
where.documentId = documentId;
}
authorize(user, "read", document);
// There can be only one subscription with these props.
const subscription = await Subscription.findOne({
where,
where: {
userId: user.id,
documentId: document.id,
event,
},
rejectOnEmpty: true,
});
@@ -112,28 +84,20 @@ router.post(
validate(T.SubscriptionsCreateSchema),
transaction(),
async (ctx: APIContext<T.SubscriptionsCreateReq>) => {
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const { event, collectionId, documentId } = ctx.input.body;
const { documentId, event } = ctx.input.body;
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
const document = await Document.findByPk(documentId, {
userId: user.id,
transaction,
});
authorize(user, "subscribe", collection);
} else {
// documentId will be available here
const document = await Document.findByPk(documentId!, {
userId: user.id,
});
authorize(user, "subscribe", document);
}
authorize(user, "subscribe", document);
const subscription = await subscriptionCreator({
ctx,
documentId,
collectionId,
documentId: document.id,
event,
});
+4 -23
View File
@@ -7,7 +7,6 @@ import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent";
import { languages } from "@shared/i18n";
import { IntegrationType, TeamPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { Day } from "@shared/utils/time";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
@@ -139,29 +138,11 @@ router.get("*", shareDomains(), async (ctx, next) => {
}
const team = await getTeamFromContext(ctx);
let redirectUrl;
if (env.isCloudHosted) {
// Redirect all requests to custom domain if one is set
if (team?.domain && team.domain !== ctx.hostname) {
redirectUrl = ctx.href.replace(ctx.hostname, team.domain);
}
// Redirect if subdomain is not the current team's subdomain
else if (team?.subdomain) {
const { teamSubdomain } = parseDomain(ctx.href);
if (team?.subdomain !== teamSubdomain) {
redirectUrl = ctx.href.replace(
`//${teamSubdomain}.`,
`//${team.subdomain}.`
);
}
}
if (redirectUrl) {
ctx.redirect(redirectUrl);
return;
}
// Redirect all requests to custom domain if one is set
if (team?.domain && team.domain !== ctx.hostname) {
ctx.redirect(ctx.href.replace(ctx.hostname, team.domain));
return;
}
const analytics = team
+3 -6
View File
@@ -1,10 +1,10 @@
import { Blob } from "buffer";
import { Readable } from "stream";
import { PresignedPost } from "@aws-sdk/s3-presigned-post";
import { isBase64Url, isInternalUrl } from "@shared/utils/urls";
import { isBase64Url } from "@shared/utils/urls";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import fetch, { chromeUserAgent, RequestInit } from "@server/utils/fetch";
import fetch, { RequestInit } from "@server/utils/fetch";
export default abstract class BaseStorage {
/** The default number of seconds until a signed URL expires. */
@@ -149,7 +149,7 @@ export default abstract class BaseStorage {
const endpoint = this.getUploadUrl(true);
// Early return if url is already uploaded to the storage provider
if (url.startsWith(endpoint) || isInternalUrl(url)) {
if (url.startsWith("/api") || url.startsWith(endpoint)) {
return;
}
@@ -168,9 +168,6 @@ export default abstract class BaseStorage {
options?.maxUploadSize ?? Infinity,
env.FILE_STORAGE_UPLOAD_MAX_SIZE
),
headers: {
"User-Agent": chromeUserAgent,
},
timeout: 10000,
...init,
});
+2 -3
View File
@@ -15,7 +15,6 @@ import {
NotificationEventType,
ProsemirrorData,
ReactionSummary,
SubscriptionType,
UserRole,
} from "@shared/types";
import { parser, schema } from "@server/editor";
@@ -121,7 +120,7 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
overrides.userId = user.id;
}
if (!overrides.documentId && !overrides.collectionId) {
if (!overrides.documentId) {
const document = await buildDocument({
createdById: overrides.userId,
teamId: user.teamId,
@@ -130,7 +129,7 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
}
return Subscription.create({
event: SubscriptionType.Document,
event: "documents.update",
...overrides,
});
}
+1 -6
View File
@@ -182,6 +182,7 @@ export type DocumentEvent = BaseEvent<Document> &
name:
| "documents.create"
| "documents.publish"
| "documents.unpublish"
| "documents.delete"
| "documents.permanent_delete"
| "documents.archive"
@@ -193,11 +194,6 @@ export type DocumentEvent = BaseEvent<Document> &
source?: "import";
};
}
| {
name: "documents.unpublish";
documentId: string;
collectionId: string;
}
| {
name: "documents.unarchive";
documentId: string;
@@ -427,7 +423,6 @@ export type SubscriptionEvent = BaseEvent<Subscription> & {
modelId: string;
userId: string;
documentId: string | null;
collectionId: string | null;
};
export type ViewEvent = BaseEvent<View> & {
-7
View File
@@ -7,13 +7,6 @@ import Logger from "@server/logging/Logger";
export type { RequestInit } from "node-fetch";
/**
* Fake Chrome user agent string for use in fetch requests to
* improve reliability.
*/
export const chromeUserAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
/**
* Wrapper around fetch that uses the request-filtering-agent in cloud hosted
* environments to filter malicious requests, and the fetch-with-proxy library
+3 -1
View File
@@ -127,7 +127,9 @@ export async function getTeamFromContext(ctx: Context) {
} else if (domain.custom) {
team = await Team.findOne({ where: { domain: domain.host } });
} else if (domain.teamSubdomain) {
team = await Team.findBySubdomain(domain.teamSubdomain);
team = await Team.findOne({
where: { subdomain: domain.teamSubdomain },
});
}
return team;
+1 -1
View File
@@ -33,7 +33,7 @@ const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<text
x="50%"
y="55%"
y={"55%"}
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.7}
-1
View File
@@ -71,7 +71,6 @@ const Content = styled.p<{ $width: number; $isSelected: boolean }>`
cursor: text;
width: ${(props) => props.$width}px;
min-width: 200px;
max-width: 100%;
${breakpoint("tablet")`
font-size: 13px;
-2
View File
@@ -55,7 +55,6 @@ const mathStyle = (props: Props) => css`
cursor: auto;
white-space: pre-wrap;
overflow-x: auto;
overflow-y: none;
}
.math-node.empty-math .math-render::before {
@@ -1096,7 +1095,6 @@ ol {
direction: rtl;
}
&:has(p:dir(rtl)),
&:dir(rtl) {
margin: 0 ${props.staticHTML ? "0" : "-26px"} 0 0.1em;
padding: 0 48px 0 0;
+11
View File
@@ -16,6 +16,17 @@ test("returns true for bullet list", () => {
).toBe(true);
});
test("returns true for numbered list", () => {
expect(
isMarkdown(`1. item one
1. item two`)
).toBe(true);
expect(
isMarkdown(`1. item one
2. item two`)
).toBe(true);
});
test("returns true for code fence", () => {
expect(
isMarkdown(`\`\`\`javascript

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