mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bee7911bee | |||
| 86714a353f | |||
| fd5391cbb6 | |||
| 6e685ee8d9 | |||
| b595a0d427 | |||
| 1c86119065 | |||
| c629006642 | |||
| 326f733d4c | |||
| d4d683c046 | |||
| 8204ac343f | |||
| cae8de7c7a | |||
| 8efa601967 | |||
| 86c3ea8e9d | |||
| c222782534 | |||
| 19ea7ee52b | |||
| d1de84a07e | |||
| d73b4c55bf | |||
| 9843c4c995 | |||
| 685397b057 | |||
| 13d37d4207 | |||
| 7bedfab301 | |||
| db5850ac0d | |||
| a4c40ce25e | |||
| f5457e79cd | |||
| 73eeeefb25 | |||
| 54f82cac96 | |||
| bb43c24efe | |||
| acf3d7cd08 | |||
| 5245f93642 | |||
| cfce55250e | |||
| 6421995b29 | |||
| 8cfd8e25db | |||
| 1282e9653e | |||
| f1edaecf49 | |||
| f7d737ca45 | |||
| 41c2c760d4 | |||
| f692d1bc3a | |||
| 5197d6e18c | |||
| b901ea7b30 | |||
| 3820499856 | |||
| 0cffde63ab | |||
| 449ba6488e | |||
| 62f3e6921f | |||
| bc259316f7 | |||
| 98e03cc227 | |||
| 633e547d3e | |||
| d5de69fd4b | |||
| feec01f160 | |||
| aa5813032e | |||
| a6ba189180 | |||
| 4c65bbc57c | |||
| c76b4f46aa | |||
| ca17b41c53 | |||
| 9747c6ba5d | |||
| 55ffd6d098 | |||
| 9b26ccda19 | |||
| 56b38b9dbd | |||
| 0a3a684493 |
@@ -4,12 +4,6 @@ defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
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
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
@@ -78,6 +72,14 @@ jobs:
|
||||
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:
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.80.2
|
||||
Licensed Work: Outline 0.81.0
|
||||
The Licensed Work is (c) 2024 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: 2028-09-26
|
||||
Change Date: 2028-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
@@ -129,11 +131,30 @@ export const createDocumentFromTemplate = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
@@ -1119,6 +1140,42 @@ export const toggleViewerInsights = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <LogoutIcon />,
|
||||
visible: ({ currentUserId, activeDocumentId, stores }) => {
|
||||
const membership = stores.userMemberships.orderedData.find(
|
||||
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
|
||||
);
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
try {
|
||||
if (document && location.pathname.startsWith(document.path)) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await stores.userMemberships.delete({
|
||||
documentId: activeDocumentId,
|
||||
userId: currentUserId,
|
||||
} as UserMembership);
|
||||
|
||||
toast.success(t("You have left the shared document"));
|
||||
} catch (err) {
|
||||
toast.error(t("Could not leave document"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -1137,6 +1194,7 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
DocumentSection,
|
||||
TeamSection,
|
||||
} from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -11,26 +15,42 @@ const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
void documents.fetchTemplates();
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((item) =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createAction({
|
||||
name: item.titleWithDefault,
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
section: template.isWorkspaceTemplate
|
||||
? TeamSection
|
||||
: ActiveCollectionSection,
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return (
|
||||
stores.policies.abilities(activeCollectionId).createDocument &&
|
||||
(template.collectionId === activeCollectionId ||
|
||||
template.isWorkspaceTemplate)
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
template.isWorkspaceTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(item.collectionId ?? activeCollectionId, {
|
||||
templateId: item.id,
|
||||
newDocumentPath(template.collectionId ?? activeCollectionId, {
|
||||
templateId: template.id,
|
||||
}),
|
||||
{
|
||||
sidebarContext,
|
||||
@@ -49,9 +69,15 @@ const useTemplatesAction = () => {
|
||||
placeholder: ({ t }) => t("Choose a template"),
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: () => actions,
|
||||
}),
|
||||
[actions]
|
||||
|
||||
@@ -39,6 +39,7 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -122,13 +123,13 @@ function DocumentCard(props: Props) {
|
||||
<Squircle
|
||||
color={
|
||||
collection?.color ??
|
||||
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||
(pinnedToHome ? theme.slateLight : theme.slateDark)
|
||||
}
|
||||
>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
pinnedToHome ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
) : (
|
||||
<DocumentIcon color="white" />
|
||||
|
||||
@@ -268,14 +268,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -47,14 +47,16 @@ export default function LanguagePrompt() {
|
||||
<br />
|
||||
<Link
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
ui.set({ languagePromptDismissed: true });
|
||||
await user.save({ language });
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
|
||||
{t("Dismiss")}
|
||||
</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
|
||||
@@ -6,9 +6,11 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
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;
|
||||
@@ -30,7 +32,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event, index) => (
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -11,6 +10,7 @@ import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
@@ -98,15 +98,22 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</PopoverButton>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
@@ -151,8 +158,4 @@ const Placeholder = React.memo(
|
||||
);
|
||||
Placeholder.displayName = "ReactionPickerPlaceholder";
|
||||
|
||||
const PopoverButton = styled(NudeButton)`
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default ReactionPicker;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -50,6 +51,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (event) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
try {
|
||||
@@ -153,6 +167,32 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
|
||||
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
ui.setRightSidebarWidth(width);
|
||||
ui.set({ sidebarRightWidth: width });
|
||||
},
|
||||
[minWidth, maxWidth, ui]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setRightSidebarWidth(theme.sidebarRightWidth);
|
||||
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
|
||||
}, [ui, theme.sidebarRightWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
|
||||
@@ -46,7 +46,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
@@ -62,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
ui.set({
|
||||
sidebarWidth: isSmallerThanCollapsePoint
|
||||
? theme.sidebarCollapsedWidth
|
||||
: width,
|
||||
});
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
@@ -86,13 +85,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
setWidth(width);
|
||||
ui.set({ sidebarWidth: width });
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setHovering(false);
|
||||
@@ -149,11 +148,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
}, [ui, minWidth, isCollapsing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
@@ -174,7 +173,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
ui.set({ sidebarWidth: theme.sidebarWidth });
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -216,8 +216,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const link = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
const isAttachmentSelection =
|
||||
@@ -266,7 +265,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showLinkToolbar = link && range;
|
||||
const showLinkToolbar =
|
||||
link && link.from === selection.from && link.to === selection.to;
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
@@ -276,12 +276,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
>
|
||||
{showLinkToolbar ? (
|
||||
<LinkEditor
|
||||
key={`${range.from}-${range.to}`}
|
||||
key={`${link.from}-${link.to}`}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
mark={range.mark}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
mark={link.mark}
|
||||
from={link.from}
|
||||
to={link.to}
|
||||
onClickLink={props.onClickLink}
|
||||
onSearchLink={props.onSearchLink}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
|
||||
@@ -248,14 +248,19 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
|
||||
while ((m = search.exec(deburr(text)))) {
|
||||
// We construct a string with the text stripped of diacritics plus the original text for
|
||||
// search allowing to search for diacritics-insensitive matches easily.
|
||||
while ((m = search.exec(deburr(text) + text))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index > text.length ? m.index - text.length : m.index;
|
||||
|
||||
this.results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
from: pos + i,
|
||||
to: pos + i + m[0].length,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -690,7 +690,10 @@ export class Editor extends React.PureComponent<
|
||||
* @param commentId The id of the comment to remove
|
||||
* @param attrs The attributes to update
|
||||
*/
|
||||
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
|
||||
public updateComment = (
|
||||
commentId: string,
|
||||
attrs: { resolved?: boolean; draft?: boolean }
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
|
||||
|
||||
@@ -214,7 +214,6 @@ export default function formattingMenuItems(
|
||||
name: "link",
|
||||
tooltip: dictionary.createLink,
|
||||
icon: <LinkIcon />,
|
||||
active: isMarkActive(schema.marks.link),
|
||||
attrs: { href: "" },
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
leaveDocument,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
@@ -298,6 +299,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
actionToMenuItem(leaveDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
|
||||
@@ -254,7 +254,8 @@ export default class Document extends ArchivableModel {
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
const prefix = this.template ? settingsPath("templates") : "/doc";
|
||||
const prefix =
|
||||
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
|
||||
|
||||
if (!this.title) {
|
||||
return `${prefix}/untitled-${this.urlId}`;
|
||||
|
||||
+18
-11
@@ -1,21 +1,29 @@
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Event extends Model {
|
||||
class Event<T extends Model> extends Model {
|
||||
static modelName = "Event";
|
||||
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
modelId: string | null | undefined;
|
||||
modelId: string | undefined;
|
||||
|
||||
actorIpAddress: string | null | undefined;
|
||||
|
||||
documentId: string;
|
||||
@Relation(() => Document)
|
||||
document: Document;
|
||||
|
||||
collectionId: string | null | undefined;
|
||||
documentId: string | undefined;
|
||||
|
||||
@Relation(() => Collection)
|
||||
collection: Collection;
|
||||
|
||||
collectionId: string | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
user: User;
|
||||
@@ -27,13 +35,12 @@ class Event extends Model {
|
||||
|
||||
actorId: string;
|
||||
|
||||
data: {
|
||||
name: string;
|
||||
email: string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
templateId: string;
|
||||
};
|
||||
data: Partial<T> | null;
|
||||
|
||||
changes: {
|
||||
attributes: Partial<T>;
|
||||
previous: Partial<T>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default Event;
|
||||
|
||||
@@ -55,6 +55,10 @@ class Share extends Model {
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
allowIndexing: boolean;
|
||||
|
||||
/** The user that shared the document. */
|
||||
@Relation(() => User, { onDelete: "null" })
|
||||
createdBy: User;
|
||||
|
||||
@@ -27,6 +27,8 @@ import { Bubble } from "./CommentThreadItem";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the form is submitted. */
|
||||
onSubmit?: () => void;
|
||||
/** Callback when the draft should be saved. */
|
||||
onSaveDraft: (data: ProsemirrorData | undefined) => void;
|
||||
/** A draft comment for this thread. */
|
||||
@@ -59,6 +61,7 @@ function CommentForm({
|
||||
documentId,
|
||||
thread,
|
||||
draft,
|
||||
onSubmit,
|
||||
onSaveDraft,
|
||||
onTyping,
|
||||
onFocus,
|
||||
@@ -119,6 +122,7 @@ function CommentForm({
|
||||
documentId,
|
||||
data: draft,
|
||||
})
|
||||
.then(() => onSubmit?.())
|
||||
.catch(() => {
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
@@ -153,11 +157,14 @@ function CommentForm({
|
||||
comment.id = uuidv4();
|
||||
comments.add(comment);
|
||||
|
||||
comment.save().catch(() => {
|
||||
comments.remove(comment.id);
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
});
|
||||
comment
|
||||
.save()
|
||||
.then(() => onSubmit?.())
|
||||
.catch(() => {
|
||||
comments.remove(comment.id);
|
||||
comment.isNew = true;
|
||||
toast.error(t("Error creating comment"));
|
||||
});
|
||||
|
||||
// optimistically update the comment model
|
||||
comment.isNew = false;
|
||||
|
||||
@@ -86,6 +86,11 @@ function CommentThread({
|
||||
});
|
||||
const can = usePolicy(document);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document.id}-${thread.id}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
const canReply = can.comment && !thread.isResolved;
|
||||
|
||||
const highlightedCommentMarks = editor
|
||||
@@ -111,6 +116,10 @@ function CommentThread({
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
editor?.updateComment(thread.id, { draft: false });
|
||||
}, [editor, thread.id]);
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
@@ -174,11 +183,6 @@ function CommentThread({
|
||||
}
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document.id}-${thread.id}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
ref={topRef}
|
||||
@@ -228,6 +232,7 @@ function CommentThread({
|
||||
{(focused || draft || commentsInThread.length === 0) && canReply && (
|
||||
<Fade timing={100}>
|
||||
<CommentForm
|
||||
onSubmit={handleSubmit}
|
||||
onSaveDraft={onSaveDraft}
|
||||
draft={draft}
|
||||
documentId={document.id}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { differenceInMilliseconds } from "date-fns";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -16,10 +17,14 @@ import Comment from "~/models/Comment";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import ReactionList from "~/components/Reactions/ReactionList";
|
||||
import ReactionPicker from "~/components/Reactions/ReactionPicker";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
@@ -242,11 +247,13 @@ function CommentThreadItem({
|
||||
onRemoveReaction={handleRemoveReaction}
|
||||
picker={
|
||||
!comment.isResolved ? (
|
||||
<StyledReactionPicker
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -257,14 +264,20 @@ function CommentThreadItem({
|
||||
<EventBoundary>
|
||||
{!isEditing && (
|
||||
<Actions gap={4} dir={dir}>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
{!comment.isResolved && (
|
||||
<StyledReactionPicker
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
)}
|
||||
<StyledMenu
|
||||
<Action
|
||||
as={CommentMenu}
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
@@ -278,6 +291,38 @@ function CommentThreadItem({
|
||||
);
|
||||
}
|
||||
|
||||
const ResolveButton = ({
|
||||
comment,
|
||||
onUpdate,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
}) => {
|
||||
const context = useActionContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Mark as resolved")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
action={resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
${(props) =>
|
||||
!props.readOnly &&
|
||||
@@ -308,25 +353,13 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const StyledMenu = styled(CommentMenu)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionPicker = styled(ReactionPicker)`
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
@@ -352,7 +385,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding-left: 4px;
|
||||
|
||||
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
|
||||
&:has(${Action}[aria-expanded="true"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -218,11 +218,11 @@ function DataLoader({ match, children }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const readOnly =
|
||||
!isEditing || !can.update || document.isArchived || !!revisionId;
|
||||
const canEdit = can.update && !document.isArchived && !revisionId;
|
||||
const readOnly = !isEditing || !canEdit;
|
||||
|
||||
return (
|
||||
<React.Fragment key={readOnly ? "readOnly" : ""}>
|
||||
<React.Fragment key={canEdit ? "edit" : "read"}>
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
|
||||
@@ -27,6 +27,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
documentHistoryPath,
|
||||
@@ -81,6 +82,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
const {
|
||||
document,
|
||||
onChangeTitle,
|
||||
@@ -103,9 +105,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focusedComment) {
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
if (
|
||||
(focusedComment.isResolved && !viewingResolved) ||
|
||||
(!focusedComment.isResolved && viewingResolved)
|
||||
) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: { commentId: focusedComment.id },
|
||||
});
|
||||
}
|
||||
ui.expandComments(document.id);
|
||||
}
|
||||
}, [focusedComment, ui, document.id]);
|
||||
}, [focusedComment, ui, document.id, history, params]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
|
||||
@@ -103,6 +103,10 @@ function DocumentHeader({
|
||||
});
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
@@ -129,7 +133,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={showContents ? ui.hideTableOfContents : ui.showTableOfContents}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
@@ -180,7 +184,7 @@ function DocumentHeader({
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents,
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Empty from "~/components/Empty";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
|
||||
const EMPTY_ARRAY: Event[] = [];
|
||||
const EMPTY_ARRAY: Event<Document>[] = [];
|
||||
|
||||
function History() {
|
||||
const { events, documents } = useStores();
|
||||
|
||||
@@ -61,6 +61,8 @@ function ReferenceListItem({
|
||||
}: Props) {
|
||||
const { icon, color } = document;
|
||||
const isEmoji = determineIconType(icon) === IconType.Emoji;
|
||||
const title =
|
||||
document instanceof Document ? document.titleWithDefault : document.title;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -81,9 +83,7 @@ function ReferenceListItem({
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
)}
|
||||
<Title>
|
||||
{isEmoji ? document.title.replace(icon!, "") : document.title}
|
||||
</Title>
|
||||
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
|
||||
</Content>
|
||||
</DocumentLink>
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
|
||||
<Trans>{{ collectionCount }} collections</Trans>
|
||||
</strong>
|
||||
</Tooltip>
|
||||
.
|
||||
.{" "}
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
|
||||
@@ -5,6 +5,124 @@ import { Trans } from "react-i18next";
|
||||
import Notice from "~/components/Notice";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
|
||||
function Message({ notice }: { notice: string }) {
|
||||
switch (notice) {
|
||||
case "domain-not-allowed":
|
||||
return (
|
||||
<Trans>
|
||||
The domain associated with your email address has not been allowed for
|
||||
this workspace.
|
||||
</Trans>
|
||||
);
|
||||
case "domain-required":
|
||||
return (
|
||||
<Trans>
|
||||
Unable to sign-in. Please navigate to your workspace's custom URL,
|
||||
then try to sign-in again.
|
||||
<hr />
|
||||
If you were invited to a workspace, you will find a link to it in the
|
||||
invite email.
|
||||
</Trans>
|
||||
);
|
||||
case "gmail-account-creation":
|
||||
return (
|
||||
<Trans>
|
||||
Sorry, a new account cannot be created with a personal Gmail address.
|
||||
<hr />
|
||||
Please use a Google Workspaces account instead.
|
||||
</Trans>
|
||||
);
|
||||
case "pending-deletion":
|
||||
return (
|
||||
<Trans>
|
||||
The workspace associated with your user is scheduled for deletion and
|
||||
cannot be accessed at this time.
|
||||
</Trans>
|
||||
);
|
||||
case "maximum-reached":
|
||||
return (
|
||||
<Trans>
|
||||
The workspace you authenticated with is not authorized on this
|
||||
installation. Try another?
|
||||
</Trans>
|
||||
);
|
||||
case "malformed-user-info":
|
||||
return (
|
||||
<Trans>
|
||||
We could not read the user info supplied by your identity provider.
|
||||
</Trans>
|
||||
);
|
||||
case "email-auth-required":
|
||||
return (
|
||||
<Trans>
|
||||
Your account uses email sign-in, please sign-in with email to
|
||||
continue.
|
||||
</Trans>
|
||||
);
|
||||
case "email-auth-ratelimit":
|
||||
return (
|
||||
<Trans>
|
||||
An email sign-in link was recently sent, please check your inbox or
|
||||
try again in a few minutes.
|
||||
</Trans>
|
||||
);
|
||||
case "auth-error":
|
||||
case "state-mismatch":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – we were unable to sign you in at this time.
|
||||
Please try again.
|
||||
</Trans>
|
||||
);
|
||||
case "invalid-authentication":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – you do not have permission to access this
|
||||
workspace.
|
||||
</Trans>
|
||||
);
|
||||
case "expired-token":
|
||||
return (
|
||||
<Trans>
|
||||
Sorry, it looks like that sign-in link is no longer valid, please try
|
||||
requesting another.
|
||||
</Trans>
|
||||
);
|
||||
case "user-suspended":
|
||||
return (
|
||||
<Trans>
|
||||
Your account has been suspended. To re-activate your account, please
|
||||
contact a workspace admin.
|
||||
</Trans>
|
||||
);
|
||||
case "team-suspended":
|
||||
return (
|
||||
<Trans>
|
||||
This workspace has been suspended. Please contact support to restore
|
||||
access.
|
||||
</Trans>
|
||||
);
|
||||
case "authentication-provider-disabled":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
admin.
|
||||
</Trans>
|
||||
);
|
||||
case "invite-required":
|
||||
return (
|
||||
<Trans>
|
||||
The workspace you are trying to join requires an invite before you can
|
||||
create an account.
|
||||
<hr />
|
||||
Please request an invite from your workspace admin and try again.
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
return <Trans>Sorry, an unknown error occurred.</Trans>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Notices() {
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
@@ -15,107 +133,7 @@ export default function Notices() {
|
||||
|
||||
return (
|
||||
<Notice icon={<WarningIcon color="currentcolor" />}>
|
||||
{notice === "domain-not-allowed" && (
|
||||
<Trans>
|
||||
The domain associated with your email address has not been allowed for
|
||||
this workspace.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "domain-required" && (
|
||||
<Trans>
|
||||
Unable to sign-in. Please navigate to your workspace's custom URL,
|
||||
then try to sign-in again.
|
||||
<hr />
|
||||
If you were invited to a workspace, you will find a link to it in the
|
||||
invite email.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "gmail-account-creation" && (
|
||||
<Trans>
|
||||
Sorry, a new account cannot be created with a personal Gmail address.
|
||||
<hr />
|
||||
Please use a Google Workspaces account instead.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "pending-deletion" && (
|
||||
<Trans>
|
||||
The workspace associated with your user is scheduled for deletion and
|
||||
cannot be accessed at this time.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "maximum-reached" && (
|
||||
<Trans>
|
||||
The workspace you authenticated with is not authorized on this
|
||||
installation. Try another?
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "malformed-user-info" && (
|
||||
<Trans>
|
||||
We could not read the user info supplied by your identity provider.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "email-auth-required" && (
|
||||
<Trans>
|
||||
Your account uses email sign-in, please sign-in with email to
|
||||
continue.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "email-auth-ratelimit" && (
|
||||
<Trans>
|
||||
An email sign-in link was recently sent, please check your inbox or
|
||||
try again in a few minutes.
|
||||
</Trans>
|
||||
)}
|
||||
{(notice === "auth-error" || notice === "state-mismatch") && (
|
||||
<Trans>
|
||||
Authentication failed – we were unable to sign you in at this time.
|
||||
Please try again.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "invalid-authentication" && (
|
||||
<Trans>
|
||||
Authentication failed – you do not have permission to access this
|
||||
workspace.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "expired-token" && (
|
||||
<Trans>
|
||||
Sorry, it looks like that sign-in link is no longer valid, please try
|
||||
requesting another.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "user-suspended" && (
|
||||
<Trans>
|
||||
Your account has been suspended. To re-activate your account, please
|
||||
contact a workspace admin.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "team-suspended" && (
|
||||
<Trans>
|
||||
This workspace has been suspended. Please contact support to restore
|
||||
access.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "authentication-provider-disabled" && (
|
||||
<Trans>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
admin.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "invite-required" && (
|
||||
<Trans>
|
||||
The workspace you are trying to join requires an invite before you can
|
||||
create an account.
|
||||
<hr />
|
||||
Please request an invite from your workspace admin and try again.
|
||||
</Trans>
|
||||
)}
|
||||
{notice === "domain-not-allowed" && (
|
||||
<Trans>
|
||||
Sorry, your domain is not allowed. Please try again with an allowed
|
||||
workspace domain.
|
||||
</Trans>
|
||||
)}
|
||||
<Message notice={notice} />
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "isAdmin",
|
||||
id: "role",
|
||||
Header: t("Role"),
|
||||
accessor: "rank",
|
||||
Cell: observer(({ row }: { row: { original: User } }) => (
|
||||
|
||||
+9
-20
@@ -7,31 +7,19 @@ import { CustomTheme } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import env from "~/env";
|
||||
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { PartialExcept } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Store from "./base/Store";
|
||||
|
||||
type PersistedData = {
|
||||
user?: PartialExcept<User, "id">;
|
||||
team?: PartialExcept<Team, "id">;
|
||||
collaborationToken?: string;
|
||||
availableTeams?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
url: string;
|
||||
isSignedIn: boolean;
|
||||
}[];
|
||||
policies?: Policy[];
|
||||
};
|
||||
type PersistedData = Pick<
|
||||
AuthStore,
|
||||
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
|
||||
>;
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
@@ -165,9 +153,10 @@ export default class AuthStore extends Store<Team> {
|
||||
/** The current team's policies */
|
||||
@computed
|
||||
get policies() {
|
||||
return this.currentTeamId
|
||||
? [this.rootStore.policies.get(this.currentTeamId)]
|
||||
: [];
|
||||
const policy = this.currentTeamId
|
||||
? this.rootStore.policies.get(this.currentTeamId)
|
||||
: undefined;
|
||||
return policy ? [policy] : [];
|
||||
}
|
||||
|
||||
/** Whether the user is signed in */
|
||||
@@ -177,7 +166,7 @@ export default class AuthStore extends Store<Team> {
|
||||
}
|
||||
|
||||
@computed
|
||||
get asJson() {
|
||||
get asJson(): PersistedData {
|
||||
return {
|
||||
user: this.user,
|
||||
team: this.team,
|
||||
|
||||
@@ -68,12 +68,16 @@ export default class PresenceStore {
|
||||
|
||||
@action
|
||||
private update(documentId: string, userId: string, isEditing: boolean) {
|
||||
const existing = this.data.get(documentId) || new Map();
|
||||
existing.set(userId, {
|
||||
isEditing,
|
||||
userId,
|
||||
});
|
||||
this.data.set(documentId, existing);
|
||||
const presence = this.data.get(documentId) || new Map();
|
||||
const existing = presence.get(userId);
|
||||
|
||||
if (!existing || existing.isEditing !== isEditing) {
|
||||
presence.set(userId, {
|
||||
isEditing,
|
||||
userId,
|
||||
});
|
||||
this.data.set(documentId, presence);
|
||||
}
|
||||
}
|
||||
|
||||
public get(documentId: string): DocumentPresence | null | undefined {
|
||||
|
||||
@@ -368,6 +368,10 @@ export default class DocumentsStore extends Store<Document> {
|
||||
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("list", { ...options, template: true });
|
||||
|
||||
@action
|
||||
fetchAllTemplates = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchAll({ ...options, template: true });
|
||||
|
||||
@action
|
||||
fetchAlphabetical = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("list", {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Event from "~/models/Event";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
|
||||
export default class EventsStore extends Store<Event> {
|
||||
export default class EventsStore extends Store<Event<any>> {
|
||||
actions = [RPCAction.List];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
@@ -12,7 +12,7 @@ export default class EventsStore extends Store<Event> {
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Event[] {
|
||||
get orderedData(): Event<any>[] {
|
||||
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
|
||||
}
|
||||
}
|
||||
|
||||
+30
-43
@@ -1,4 +1,4 @@
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
@@ -23,15 +23,16 @@ export enum SystemTheme {
|
||||
Dark = "dark",
|
||||
}
|
||||
|
||||
type PersistedData = {
|
||||
languagePromptDismissed: boolean | undefined;
|
||||
theme: Theme;
|
||||
sidebarCollapsed: boolean;
|
||||
sidebarWidth: number;
|
||||
sidebarRightWidth: number;
|
||||
tocVisible: boolean | undefined;
|
||||
commentsExpanded: string[];
|
||||
};
|
||||
type PersistedData = Pick<
|
||||
UiStore,
|
||||
| "languagePromptDismissed"
|
||||
| "commentsExpanded"
|
||||
| "theme"
|
||||
| "sidebarWidth"
|
||||
| "sidebarRightWidth"
|
||||
| "sidebarCollapsed"
|
||||
| "tocVisible"
|
||||
>;
|
||||
|
||||
class UiStore {
|
||||
// has the user seen the prompt to change the UI language and actioned it
|
||||
@@ -134,10 +135,6 @@ class UiStore {
|
||||
this.tocVisible = newData.tocVisible;
|
||||
}
|
||||
});
|
||||
|
||||
autorun(() => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -147,12 +144,7 @@ class UiStore {
|
||||
this.theme = theme;
|
||||
});
|
||||
});
|
||||
Storage.set("theme", this.theme);
|
||||
};
|
||||
|
||||
@action
|
||||
setLanguagePromptDismissed = () => {
|
||||
this.languagePromptDismissed = true;
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -205,25 +197,24 @@ class UiStore {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarWidth = (width: number): void => {
|
||||
this.sidebarWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
setRightSidebarWidth = (width: number): void => {
|
||||
this.sidebarRightWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
collapseSidebar = () => {
|
||||
this.sidebarCollapsed = true;
|
||||
this.set({ sidebarCollapsed: true });
|
||||
};
|
||||
|
||||
@action
|
||||
expandSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.sidebarCollapsed = false;
|
||||
this.set({ sidebarCollapsed: false });
|
||||
};
|
||||
|
||||
@action
|
||||
set = (data: Partial<PersistedData>) => {
|
||||
for (const key in data) {
|
||||
// @ts-expect-error doesn't understand PersistedData is subset of keys
|
||||
this[key] = data[key];
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -231,6 +222,7 @@ class UiStore {
|
||||
this.commentsExpanded = this.commentsExpanded.filter(
|
||||
(id) => id !== documentId
|
||||
);
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -238,6 +230,7 @@ class UiStore {
|
||||
if (!this.commentsExpanded.includes(documentId)) {
|
||||
this.commentsExpanded.push(documentId);
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -252,17 +245,7 @@ class UiStore {
|
||||
@action
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
};
|
||||
|
||||
@action
|
||||
showTableOfContents = () => {
|
||||
this.tocVisible = true;
|
||||
};
|
||||
|
||||
@action
|
||||
hideTableOfContents = () => {
|
||||
this.tocVisible = false;
|
||||
this.set({ sidebarCollapsed: !this.sidebarCollapsed });
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -324,6 +307,10 @@ class UiStore {
|
||||
theme: this.theme,
|
||||
};
|
||||
}
|
||||
|
||||
private persist = () => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
};
|
||||
}
|
||||
|
||||
export default UiStore;
|
||||
|
||||
+17
-16
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.616.0",
|
||||
"@aws-sdk/lib-storage": "3.616.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.616.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.616.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.616.0",
|
||||
"@aws-sdk/client-s3": "3.693.0",
|
||||
"@aws-sdk/lib-storage": "3.693.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.693.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.693.0",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
@@ -79,11 +79,11 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@octokit/auth-app": "^6.1.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.117.0",
|
||||
"@sentry/node": "^7.119.0",
|
||||
"@sentry/react": "^7.119.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
@@ -150,7 +150,7 @@
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mermaid": "9.3.0",
|
||||
"mermaid": "11.4.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
@@ -184,7 +184,7 @@
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"prosemirror-view": "^1.36.0",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
@@ -208,6 +208,7 @@
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.10",
|
||||
"reakit": "^1.3.11",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
@@ -248,13 +249,13 @@
|
||||
"y-protocols": "^1.0.6",
|
||||
"yauzl": "^2.10.0",
|
||||
"yjs": "^13.6.1",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.12",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -270,7 +271,7 @@
|
||||
"@types/glob": "^8.0.1",
|
||||
"@types/google.analytics": "^0.0.46",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/koa": "^2.15.0",
|
||||
@@ -285,7 +286,6 @@
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-container": "^2.0.9",
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mermaid": "^9.2.0",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.14.2",
|
||||
@@ -341,7 +341,7 @@
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^7.9.0",
|
||||
"jest-cli": "^29.7.0",
|
||||
@@ -354,12 +354,13 @@
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.32.0",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"d3": "^7.0.0",
|
||||
"debug": "4.3.4",
|
||||
@@ -368,5 +369,5 @@
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
},
|
||||
"version": "0.80.2"
|
||||
"version": "0.81.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,9 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Attachment, Event, User } from "@server/models";
|
||||
import { Attachment, User } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RequestInit } from "@server/utils/fetch";
|
||||
|
||||
type BaseProps = {
|
||||
@@ -17,10 +17,8 @@ type BaseProps = {
|
||||
source?: "import";
|
||||
/** The preset to use for the attachment */
|
||||
preset: AttachmentPreset;
|
||||
/** The IP address of the user creating the attachment, if available. */
|
||||
ip?: string;
|
||||
/** The database transaction to use for the creation */
|
||||
transaction?: Transaction;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
/** Options to pass to fetch when downloading the attachment */
|
||||
fetchOptions?: RequestInit;
|
||||
};
|
||||
@@ -42,8 +40,7 @@ export default async function attachmentCreator({
|
||||
user,
|
||||
source,
|
||||
preset,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
fetchOptions,
|
||||
...rest
|
||||
}: Props): Promise<Attachment | undefined> {
|
||||
@@ -64,20 +61,15 @@ export default async function attachmentCreator({
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
attachment = await Attachment.createWithCtx(ctx, {
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
} else {
|
||||
const { buffer, type } = rest;
|
||||
await FileStorage.store({
|
||||
@@ -88,38 +80,16 @@ export default async function attachmentCreator({
|
||||
acl,
|
||||
});
|
||||
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "attachments.create",
|
||||
data: {
|
||||
name,
|
||||
source,
|
||||
},
|
||||
modelId: attachment.id,
|
||||
attachment = await Attachment.createWithCtx(ctx, {
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
@@ -6,8 +5,9 @@ import {
|
||||
FileOperationState,
|
||||
} from "@shared/types";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Collection, Event, Team, User, FileOperation } from "@server/models";
|
||||
import { Collection, Team, User, FileOperation } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import { type APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
collection?: Collection;
|
||||
@@ -15,8 +15,7 @@ type Props = {
|
||||
user: User;
|
||||
format?: FileOperationFormat;
|
||||
includeAttachments?: boolean;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
function getKeyForFileOp(
|
||||
@@ -35,8 +34,7 @@ async function collectionExporter({
|
||||
user,
|
||||
format = FileOperationFormat.MarkdownZip,
|
||||
includeAttachments = true,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props) {
|
||||
const collectionId = collection?.id;
|
||||
const key = getKeyForFileOp(
|
||||
@@ -44,43 +42,20 @@ async function collectionExporter({
|
||||
format,
|
||||
collection?.name || team.name
|
||||
);
|
||||
const fileOperation = await FileOperation.create(
|
||||
{
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
const fileOperation = await FileOperation.createWithCtx(ctx, {
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "fileOperations.create",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: fileOperation.id,
|
||||
collectionId,
|
||||
ip,
|
||||
data: {
|
||||
type: FileOperationType.Export,
|
||||
format,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
fileOperation.user = user;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Optional } from "utility-types";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = Optional<
|
||||
Pick<
|
||||
@@ -31,8 +31,7 @@ type Props = Optional<
|
||||
publish?: boolean;
|
||||
templateDocument?: Document | null;
|
||||
user: User;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
export default async function documentCreator({
|
||||
@@ -58,9 +57,9 @@ export default async function documentCreator({
|
||||
editorVersion,
|
||||
publishedAt,
|
||||
sourceMetadata,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props): Promise<Document> {
|
||||
const { transaction, ip } = ctx.context;
|
||||
const templateId = templateDocument ? templateDocument.id : undefined;
|
||||
|
||||
if (state && templateDocument) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createContext } from "@server/context";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import documentDuplicator from "./documentDuplicator";
|
||||
|
||||
describe("documentDuplicator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should duplicate existing document", async () => {
|
||||
const user = await buildUser();
|
||||
const original = await buildDocument({
|
||||
@@ -16,9 +15,8 @@ describe("documentDuplicator", () => {
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
transaction,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,9 +41,8 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
title: "New title",
|
||||
transaction,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,9 +74,8 @@ describe("documentDuplicator", () => {
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
user,
|
||||
transaction,
|
||||
recursive: true,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -97,10 +93,9 @@ describe("documentDuplicator", () => {
|
||||
documentDuplicator({
|
||||
document: original,
|
||||
collection: original.collection,
|
||||
transaction,
|
||||
publish: false,
|
||||
user,
|
||||
ip,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Transaction, Op } from "sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import { User, Collection, Document } from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
import documentCreator from "./documentCreator";
|
||||
|
||||
type Props = {
|
||||
@@ -19,10 +20,8 @@ type Props = {
|
||||
publish?: boolean;
|
||||
/** Whether to duplicate child documents */
|
||||
recursive?: boolean;
|
||||
/** The database transaction to use for the creation */
|
||||
transaction?: Transaction;
|
||||
/** The IP address of the request */
|
||||
ip: string;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
export default async function documentDuplicator({
|
||||
@@ -33,16 +32,14 @@ export default async function documentDuplicator({
|
||||
title,
|
||||
publish,
|
||||
recursive,
|
||||
transaction,
|
||||
ip,
|
||||
ctx,
|
||||
}: Props): Promise<Document[]> {
|
||||
const newDocuments: Document[] = [];
|
||||
const sharedProperties = {
|
||||
user,
|
||||
collectionId: collection?.id,
|
||||
publish: publish ?? !!document.publishedAt,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
};
|
||||
|
||||
const duplicated = await documentCreator({
|
||||
@@ -76,9 +73,7 @@ export default async function documentDuplicator({
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
ctx
|
||||
);
|
||||
|
||||
for (const childDocument of childDocuments) {
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import { createContext } from "@server/context";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import documentImporter from "./documentImporter";
|
||||
|
||||
jest.mock("@server/storage/files");
|
||||
|
||||
describe("documentImporter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should convert Word Document to markdown", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "images.docx";
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -40,13 +43,15 @@ describe("documentImporter", () => {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -67,13 +72,15 @@ describe("documentImporter", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
@@ -87,13 +94,15 @@ describe("documentImporter", () => {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/octet-stream",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
const attachments = await Attachment.count({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -112,13 +121,15 @@ describe("documentImporter", () => {
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("Text paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -129,13 +140,16 @@ describe("documentImporter", () => {
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/msword",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/msword",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.text).toContain("this is a test document");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -147,13 +161,15 @@ describe("documentImporter", () => {
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -162,13 +178,16 @@ describe("documentImporter", () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "markdown.md";
|
||||
const content = `# Title`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.text).toEqual("");
|
||||
expect(response.title).toEqual("Title");
|
||||
});
|
||||
@@ -180,13 +199,15 @@ describe("documentImporter", () => {
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
"utf8"
|
||||
);
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "application/lol",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "application/lol",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toContain("This is a test paragraph");
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
@@ -200,13 +221,15 @@ describe("documentImporter", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await documentImporter({
|
||||
user,
|
||||
mimeType: "executable/zip",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "executable/zip",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
@@ -228,13 +251,15 @@ describe("documentImporter", () => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("\\$100");
|
||||
});
|
||||
|
||||
@@ -252,13 +277,15 @@ describe("documentImporter", () => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("`echo $foo`");
|
||||
});
|
||||
|
||||
@@ -276,13 +303,15 @@ describe("documentImporter", () => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
documentImporter({
|
||||
user,
|
||||
mimeType: "text/html",
|
||||
fileName,
|
||||
content,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
expect(response.text).toEqual("```\necho $foo\n```");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import truncate from "lodash/truncate";
|
||||
import { Transaction } from "sequelize";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
import { DocumentConverter } from "@server/utils/DocumentConverter";
|
||||
import { InvalidRequestError } from "../errors";
|
||||
|
||||
@@ -16,8 +16,7 @@ type Props = {
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
content: Buffer | string;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
async function documentImporter({
|
||||
@@ -25,8 +24,7 @@ async function documentImporter({
|
||||
fileName,
|
||||
content,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
}: Props): Promise<{
|
||||
icon?: string;
|
||||
text: string;
|
||||
@@ -66,12 +64,7 @@ async function documentImporter({
|
||||
// Remove any closed and immediately reopened formatting marks
|
||||
text = text.replace(/\*\*\*\*/gi, "").replace(/____/gi, "");
|
||||
|
||||
text = await TextHelper.replaceImagesWithAttachments(
|
||||
text,
|
||||
user,
|
||||
ip,
|
||||
transaction
|
||||
);
|
||||
text = await TextHelper.replaceImagesWithAttachments(ctx, text, user);
|
||||
|
||||
// Sanity check – text cannot possibly be longer than state so if it is, we can short-circuit here
|
||||
if (text.length > DocumentValidation.maxStateLength) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { FileOperation, Event, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
fileOperation: FileOperation;
|
||||
user: User;
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
};
|
||||
|
||||
export default async function fileOperationDeleter({
|
||||
fileOperation,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props) {
|
||||
await fileOperation.destroy({ transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "fileOperations.delete",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: fileOperation.id,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
import { Star, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import starCreator from "./starCreator";
|
||||
|
||||
describe("starCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create star", async () => {
|
||||
it("should create star for document", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const star = await sequelize.transaction(async (transaction) =>
|
||||
const star = await withAPIContext(user, (ctx) =>
|
||||
starCreator({
|
||||
ctx,
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -45,12 +42,11 @@ describe("starCreator", () => {
|
||||
index: "P",
|
||||
});
|
||||
|
||||
const star = await sequelize.transaction(async (transaction) =>
|
||||
const star = await withAPIContext(user, (ctx) =>
|
||||
starCreator({
|
||||
ctx,
|
||||
documentId: document.id,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Sequelize, Transaction, WhereOptions } from "sequelize";
|
||||
import { Star, User, Event } from "@server/models";
|
||||
import { Sequelize, WhereOptions } from "sequelize";
|
||||
import { Star, User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
/** The user creating the star */
|
||||
@@ -11,9 +12,8 @@ type Props = {
|
||||
collectionId?: string;
|
||||
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
|
||||
index?: string;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
/** The request context */
|
||||
ctx: APIContext;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,8 +27,7 @@ export default async function starCreator({
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
transaction,
|
||||
ctx,
|
||||
...rest
|
||||
}: Props): Promise<Star> {
|
||||
let { index } = rest;
|
||||
@@ -47,14 +46,14 @@ export default async function starCreator({
|
||||
Sequelize.literal('"star"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
transaction,
|
||||
transaction: ctx.state.transaction,
|
||||
});
|
||||
|
||||
// create a star at the beginning of the list
|
||||
index = fractionalIndex(null, stars.length ? stars[0].index : null);
|
||||
}
|
||||
|
||||
const [star, isCreated] = await Star.findOrCreate({
|
||||
const [star] = await Star.findOrCreateWithCtx(ctx, {
|
||||
where: documentId
|
||||
? {
|
||||
userId: user.id,
|
||||
@@ -67,24 +66,7 @@ export default async function starCreator({
|
||||
defaults: {
|
||||
index,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (isCreated) {
|
||||
await Event.create(
|
||||
{
|
||||
name: "stars.create",
|
||||
teamId: user.teamId,
|
||||
modelId: star.id,
|
||||
userId: user.id,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
return star;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Event, Star } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import starDestroyer from "./starDestroyer";
|
||||
|
||||
describe("starDestroyer", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should destroy existing star", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const star = await Star.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
index: "P",
|
||||
});
|
||||
|
||||
await starDestroyer({
|
||||
star,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const count = await Star.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
expect(count).toEqual(0);
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
expect(event!.name).toEqual("stars.delete");
|
||||
expect(event!.modelId).toEqual(star.id);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Star, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user destroying the star */
|
||||
user: User;
|
||||
/** The star to destroy */
|
||||
star: Star;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command destroys a document star. This just removes the star itself and
|
||||
* does not touch the document
|
||||
*
|
||||
* @param Props The properties of the star to destroy
|
||||
* @returns void
|
||||
*/
|
||||
export default async function starDestroyer({
|
||||
user,
|
||||
star,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Star> {
|
||||
await star.destroy({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "stars.delete",
|
||||
modelId: star.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
userId: star.userId,
|
||||
documentId: star.documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
return star;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Event, Star } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import starUpdater from "./starUpdater";
|
||||
|
||||
describe("starUpdater", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should update (move) existing star", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
let star = await Star.create({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
index: "P",
|
||||
});
|
||||
|
||||
star = await starUpdater({
|
||||
star,
|
||||
index: "h",
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
expect(star.documentId).toEqual(document.id);
|
||||
expect(star.userId).toEqual(user.id);
|
||||
expect(star.index).toEqual("h");
|
||||
expect(event!.name).toEqual("stars.update");
|
||||
expect(event!.modelId).toEqual(star.id);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Star, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the star */
|
||||
user: User;
|
||||
/** The existing star */
|
||||
star: Star;
|
||||
/** The index to star the document at */
|
||||
index: string;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates a "starred" document. A star can only be moved to a new
|
||||
* index (reordered) once created.
|
||||
*
|
||||
* @param Props The properties of the star to update
|
||||
* @returns Star The updated star
|
||||
*/
|
||||
export default async function starUpdater({
|
||||
user,
|
||||
star,
|
||||
index,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Star> {
|
||||
star.index = index;
|
||||
await star.save({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "stars.update",
|
||||
modelId: star.id,
|
||||
userId: star.userId,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: star.documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
return star;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
export function createContext(
|
||||
user: User,
|
||||
transaction?: Transaction,
|
||||
ip?: string
|
||||
) {
|
||||
return {
|
||||
context: {
|
||||
ip: ip ?? user.lastActiveIp,
|
||||
transaction,
|
||||
auth: { user },
|
||||
},
|
||||
} as APIContext;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export class Mailer {
|
||||
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: data.from ?? env.SMTP_FROM_EMAIL,
|
||||
from: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
|
||||
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
|
||||
to: data.to,
|
||||
messageId: data.messageId,
|
||||
|
||||
@@ -51,6 +51,15 @@ export default abstract class BaseEmail<
|
||||
* @returns A promise that resolves once the email is placed on the task queue
|
||||
*/
|
||||
public schedule(options?: Bull.JobOptions) {
|
||||
// No-op to schedule emails if SMTP is not configured
|
||||
if (!env.SMTP_FROM_EMAIL) {
|
||||
Logger.info(
|
||||
"email",
|
||||
`Email ${this.constructor.name} not sent due to missing SMTP configuration`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = this.constructor.name;
|
||||
|
||||
Metrics.increment("email.scheduled", {
|
||||
|
||||
@@ -7,6 +7,7 @@ import HTMLHelper from "@server/models/helpers/HTMLHelper";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
|
||||
import { can } from "@server/policies";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
@@ -68,21 +69,28 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
|
||||
let body;
|
||||
if (revisionId && team?.getPreference(TeamPreference.PreviewsInEmails)) {
|
||||
// generate the diff html for the email
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
body = await CacheHelper.getDataOrSet<string>(
|
||||
`diff:${revisionId}`,
|
||||
async () => {
|
||||
// generate the diff html for the email
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
|
||||
if (revision) {
|
||||
const before = await revision.before();
|
||||
const content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
signedUrls: 4 * Day.seconds,
|
||||
baseUrl: props.teamUrl,
|
||||
});
|
||||
if (revision) {
|
||||
const before = await revision.before();
|
||||
const content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
signedUrls: 4 * Day.seconds,
|
||||
baseUrl: props.teamUrl,
|
||||
});
|
||||
|
||||
// inline all css so that it works in as many email providers as possible.
|
||||
body = content ? await HTMLHelper.inlineCSS(content) : undefined;
|
||||
}
|
||||
// inline all css so that it works in as many email providers as possible.
|
||||
return content ? await HTMLHelper.inlineCSS(content) : undefined;
|
||||
}
|
||||
return;
|
||||
},
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -155,6 +155,16 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(ctx, "context", {
|
||||
get() {
|
||||
return {
|
||||
auth: ctx.state.auth,
|
||||
transaction: ctx.state.transaction,
|
||||
ip: ctx.request.ip,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn("shares", "allowIndexing", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface) => {
|
||||
await queryInterface.removeColumn("shares", "allowIndexing");
|
||||
},
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ApiKeyValidation } from "@shared/validations";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@@ -28,6 +29,8 @@ class ApiKey extends ParanoidModel<
|
||||
> {
|
||||
static prefix = "ol_api_";
|
||||
|
||||
static eventNamespace = "api_keys";
|
||||
|
||||
@Length({
|
||||
min: ApiKeyValidation.minNameLength,
|
||||
max: ApiKeyValidation.maxNameLength,
|
||||
@@ -48,10 +51,12 @@ class ApiKey extends ParanoidModel<
|
||||
/** The hashed value of the API key */
|
||||
@Unique
|
||||
@Column
|
||||
@SkipChangeset
|
||||
hash: string;
|
||||
|
||||
/** The last 4 characters of the API key */
|
||||
@Column
|
||||
@SkipChangeset
|
||||
last4: string;
|
||||
|
||||
@IsDate
|
||||
@@ -60,6 +65,7 @@ class ApiKey extends ParanoidModel<
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
// hooks
|
||||
@@ -148,7 +154,7 @@ class ApiKey extends ParanoidModel<
|
||||
this.lastActiveAt = new Date();
|
||||
}
|
||||
|
||||
return this.save();
|
||||
return this.save({ silent: true });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import Document from "./Document";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@@ -35,6 +36,8 @@ class Attachment extends IdModel<
|
||||
InferAttributes<Attachment>,
|
||||
Partial<InferCreationAttributes<Attachment>>
|
||||
> {
|
||||
static eventNamespace = "attachments";
|
||||
|
||||
@Length({
|
||||
max: 4096,
|
||||
msg: "key must be 4096 characters or less",
|
||||
@@ -59,6 +62,7 @@ class Attachment extends IdModel<
|
||||
acl: string;
|
||||
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastAccessedAt: Date | null;
|
||||
|
||||
@Column
|
||||
|
||||
@@ -55,6 +55,7 @@ class Event extends IdModel<
|
||||
|
||||
/**
|
||||
* Metadata associated with the event, previously used for storing some changed attributes.
|
||||
* Note that the `data` column will be visible to the client and API requests.
|
||||
*/
|
||||
@Column(DataType.JSONB)
|
||||
data: Record<string, any> | null;
|
||||
|
||||
@@ -51,6 +51,8 @@ class FileOperation extends ParanoidModel<
|
||||
InferAttributes<FileOperation>,
|
||||
Partial<InferCreationAttributes<FileOperation>>
|
||||
> {
|
||||
static eventNamespace = "fileOperations";
|
||||
|
||||
@Column(DataType.ENUM(...Object.values(FileOperationType)))
|
||||
type: FileOperationType;
|
||||
|
||||
|
||||
@@ -184,6 +184,10 @@ class Share extends IdModel<
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@Default(true)
|
||||
@Column
|
||||
allowIndexing: boolean;
|
||||
|
||||
revoke(userId: string) {
|
||||
this.revokedAt = new Date();
|
||||
this.revokedById = userId;
|
||||
|
||||
@@ -19,6 +19,8 @@ class Star extends IdModel<
|
||||
InferAttributes<Star>,
|
||||
Partial<InferCreationAttributes<Star>>
|
||||
> {
|
||||
static eventNamespace = "stars";
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("Model", () => {
|
||||
expect(document.changeset.previous.collaboratorIds).toEqual(prev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("batch load", () => {
|
||||
it("should return data in batches", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
+241
-4
@@ -3,14 +3,200 @@ import isEqual from "fast-deep-equal";
|
||||
import isArray from "lodash/isArray";
|
||||
import isObject from "lodash/isObject";
|
||||
import pick from "lodash/pick";
|
||||
import { FindOptions, NonAttribute } from "sequelize";
|
||||
import { Model as SequelizeModel } from "sequelize-typescript";
|
||||
import { Replace } from "@server/types";
|
||||
import {
|
||||
Attributes,
|
||||
CreateOptions,
|
||||
CreationAttributes,
|
||||
DataTypes,
|
||||
FindOptions,
|
||||
FindOrCreateOptions,
|
||||
InstanceDestroyOptions,
|
||||
InstanceUpdateOptions,
|
||||
ModelStatic,
|
||||
NonAttribute,
|
||||
SaveOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
AfterCreate,
|
||||
AfterDestroy,
|
||||
AfterUpdate,
|
||||
AfterUpsert,
|
||||
BeforeCreate,
|
||||
Model as SequelizeModel,
|
||||
} from "sequelize-typescript";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Replace, APIContext } from "@server/types";
|
||||
import { getChangsetSkipped } from "../decorators/Changeset";
|
||||
|
||||
class Model<
|
||||
TModelAttributes extends {} = any,
|
||||
TCreationAttributes extends {} = TModelAttributes
|
||||
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
|
||||
/**
|
||||
* The namespace to use for events, if none is provided an event will not be created
|
||||
* during the migration period. In the future this may default to the table name.
|
||||
*/
|
||||
static eventNamespace: string | undefined;
|
||||
|
||||
/**
|
||||
* Validates this instance, and if the validation passes, persists it to the database.
|
||||
*/
|
||||
public saveWithCtx(ctx: APIContext) {
|
||||
this.cacheChangeset();
|
||||
return this.save(ctx.context as SaveOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the same as calling `set` and then calling `save`.
|
||||
*/
|
||||
public updateWithCtx(ctx: APIContext, keys: Partial<TModelAttributes>) {
|
||||
this.set(keys);
|
||||
this.cacheChangeset();
|
||||
return this.save(ctx.context as SaveOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the row corresponding to this instance. Depending on your setting for paranoid, the row will
|
||||
* either be completely deleted, or have its deletedAt timestamp set to the current time.
|
||||
*/
|
||||
public destroyWithCtx(ctx: APIContext) {
|
||||
return this.destroy(ctx.context as InstanceDestroyOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a row that matches the query, or build and save the row if none is found
|
||||
* The successful result of the promise will be (instance, created) - Make sure to use `.then(([...]))`
|
||||
*/
|
||||
public static findOrCreateWithCtx<M extends Model>(
|
||||
this: ModelStatic<M>,
|
||||
ctx: APIContext,
|
||||
options: FindOrCreateOptions<Attributes<M>, CreationAttributes<M>>
|
||||
) {
|
||||
return this.findOrCreate({
|
||||
...options,
|
||||
...ctx.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new model instance and calls save on it.
|
||||
*/
|
||||
public static createWithCtx<M extends Model>(
|
||||
this: ModelStatic<M>,
|
||||
ctx: APIContext,
|
||||
values?: CreationAttributes<M>
|
||||
) {
|
||||
return this.create(values, ctx.context as CreateOptions);
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async beforeCreateEvent<T extends Model>(model: T) {
|
||||
model.cacheChangeset();
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static async afterCreateEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("create", model, context);
|
||||
}
|
||||
|
||||
@AfterUpsert
|
||||
static async afterUpsertEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("create", model, context);
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static async afterUpdateEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("update", model, context);
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
static async afterDestroyEvent<T extends Model>(
|
||||
model: T,
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
await this.insertEvent("delete", model, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event into the database recording a mutation to this model.
|
||||
*
|
||||
* @param name The name of the event.
|
||||
* @param model The model that was mutated.
|
||||
* @param context The API context.
|
||||
*/
|
||||
protected static async insertEvent<T extends Model>(
|
||||
name: string,
|
||||
model: T,
|
||||
context: APIContext["context"] & InstanceUpdateOptions
|
||||
) {
|
||||
const namespace = this.eventNamespace;
|
||||
const models = this.sequelize!.models;
|
||||
|
||||
// If no namespace is defined, don't create an event
|
||||
if (!namespace || context.silent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.transaction) {
|
||||
Logger.warn("No transaction provided to insertEvent", {
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.ip) {
|
||||
Logger.warn("No ip provided to insertEvent", {
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
|
||||
return models.event.create(
|
||||
{
|
||||
name: `${namespace}.${name}`,
|
||||
modelId: model.id,
|
||||
collectionId:
|
||||
"collectionId" in model
|
||||
? model.collectionId
|
||||
: model instanceof models.collection
|
||||
? model.id
|
||||
: undefined,
|
||||
documentId:
|
||||
"documentId" in model
|
||||
? model.documentId
|
||||
: model instanceof models.document
|
||||
? model.id
|
||||
: undefined,
|
||||
userId:
|
||||
"userId" in model
|
||||
? model.userId
|
||||
: model instanceof models.user
|
||||
? model.id
|
||||
: undefined,
|
||||
teamId:
|
||||
"teamId" in model
|
||||
? model.teamId
|
||||
: model instanceof models.team
|
||||
? model.id
|
||||
: context.auth?.user.teamId,
|
||||
actorId: context.auth?.user?.id,
|
||||
authType: context.auth?.type,
|
||||
ip: context.ip,
|
||||
changes: model.previousChangeset,
|
||||
},
|
||||
{
|
||||
transaction: context.transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all models in batches, calling the callback function for each batch.
|
||||
*
|
||||
@@ -38,7 +224,7 @@ class Model<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attributes that have changed since the last save and their previous values.
|
||||
* Returns a representation of the attributes that have changed since the last save and their previous values.
|
||||
*
|
||||
* @returns An object with `attributes` and `previousAttributes` keys.
|
||||
*/
|
||||
@@ -57,10 +243,22 @@ class Model<
|
||||
};
|
||||
}
|
||||
|
||||
const virtualFields = (this.constructor as typeof Model).virtualFields;
|
||||
const blobFields = (this.constructor as typeof Model).blobFields;
|
||||
const skippedFields = getChangsetSkipped(this);
|
||||
|
||||
for (const change of changes) {
|
||||
const previous = this.previous(change);
|
||||
const current = this.getDataValue(change);
|
||||
|
||||
if (
|
||||
virtualFields.includes(String(change)) ||
|
||||
blobFields.includes(String(change)) ||
|
||||
skippedFields.includes(String(change))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isObject(previous) &&
|
||||
isObject(current) &&
|
||||
@@ -91,6 +289,45 @@ class Model<
|
||||
previous: previousAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the current changeset for later use.
|
||||
*/
|
||||
protected cacheChangeset() {
|
||||
const previous = this.changeset;
|
||||
|
||||
if (
|
||||
Object.keys(previous.attributes).length > 0 ||
|
||||
Object.keys(previous.previous).length > 0
|
||||
) {
|
||||
this.previousChangeset = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the virtual fields for this model.
|
||||
*/
|
||||
protected static get virtualFields() {
|
||||
const attrs = this.rawAttributes;
|
||||
return Object.keys(attrs).filter(
|
||||
(attr) => attrs[attr].type instanceof DataTypes.VIRTUAL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blob fields for this model.
|
||||
*/
|
||||
protected static get blobFields() {
|
||||
const attrs = this.rawAttributes;
|
||||
return Object.keys(attrs).filter(
|
||||
(attr) => attrs[attr].type instanceof DataTypes.BLOB
|
||||
);
|
||||
}
|
||||
|
||||
private previousChangeset: NonAttribute<{
|
||||
attributes: Partial<TModelAttributes>;
|
||||
previous: Partial<TModelAttributes>;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
export default Model;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import "reflect-metadata";
|
||||
|
||||
const key = Symbol("skipChangeset");
|
||||
|
||||
/**
|
||||
* This decorator is used to annotate a property as being skipped from being included in a changeset.
|
||||
*/
|
||||
export function SkipChangeset(target: any, propertyKey: string) {
|
||||
const properties: string[] = Reflect.getMetadata(key, target);
|
||||
|
||||
if (!properties) {
|
||||
return Reflect.defineMetadata(key, [propertyKey], target);
|
||||
}
|
||||
|
||||
properties.push(propertyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to get the properties that should be skipped from a changeset.
|
||||
*/
|
||||
export function getChangsetSkipped(target: any): string[] {
|
||||
return Reflect.getMetadata(key, target) || [];
|
||||
}
|
||||
@@ -558,7 +558,7 @@ export class ProsemirrorHelper {
|
||||
// Inject Mermaid script
|
||||
if (mermaidElements.length) {
|
||||
element.innerHTML = `
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
fontFamily: "inherit",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import chunk from "lodash/chunk";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import startCase from "lodash/startCase";
|
||||
import { Transaction } from "sequelize";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
@@ -14,6 +13,7 @@ import env from "@server/env";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import { Attachment, User } from "@server/models";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { APIContext } from "@server/types";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import parseImages from "@server/utils/parseImages";
|
||||
|
||||
@@ -83,17 +83,15 @@ export class TextHelper {
|
||||
* Replaces remote and base64 encoded images in the given text with attachment
|
||||
* urls and uploads the images to the storage provider.
|
||||
*
|
||||
* @param ctx The API context
|
||||
* @param markdown The text to replace the images in
|
||||
* @param user The user context
|
||||
* @param ip The IP address of the user
|
||||
* @param transaction The transaction to use for the database operations
|
||||
* @returns The text with the images replaced
|
||||
*/
|
||||
static async replaceImagesWithAttachments(
|
||||
ctx: APIContext,
|
||||
markdown: string,
|
||||
user: User,
|
||||
ip?: string,
|
||||
transaction?: Transaction
|
||||
user: User
|
||||
) {
|
||||
let output = markdown;
|
||||
const images = parseImages(markdown);
|
||||
@@ -117,11 +115,10 @@ export class TextHelper {
|
||||
url: image.src,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
fetchOptions: {
|
||||
timeout: timeoutPerImage,
|
||||
},
|
||||
ctx,
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
|
||||
@@ -119,7 +119,19 @@ allow(User, "publish", Document, (actor, document) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["manageUsers", "duplicate"], Document, (actor, document) =>
|
||||
allow(User, "manageUsers", Document, (actor, document) =>
|
||||
and(
|
||||
!document?.template,
|
||||
can(actor, "update", document),
|
||||
or(
|
||||
includesMembership(document, [DocumentPermission.Admin]),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
!!document?.isDraft && actor.id === document?.createdById
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "duplicate", Document, (actor, document) =>
|
||||
and(
|
||||
can(actor, "update", document),
|
||||
or(
|
||||
|
||||
@@ -13,10 +13,12 @@ export default function presentEvent(event: Event, isAdmin = false) {
|
||||
documentId: event.documentId,
|
||||
createdAt: event.createdAt,
|
||||
data: event.data,
|
||||
changes: event.changes || undefined,
|
||||
actor: presentUser(event.actor),
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
delete data.changes;
|
||||
delete data.actorIpAddress;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function presentShare(share: Share, isAdmin = false) {
|
||||
urlId: share.urlId,
|
||||
createdBy: presentUser(share.user),
|
||||
includeChildDocuments: share.includeChildDocuments,
|
||||
allowIndexing: share.allowIndexing,
|
||||
lastAccessedAt: share.lastAccessedAt || undefined,
|
||||
views: share.views || 0,
|
||||
domain: share.domain,
|
||||
|
||||
+5
-2
@@ -5,8 +5,11 @@ import { Event as TEvent, CollectionEvent } from "@server/types";
|
||||
import DetachDraftsFromCollectionTask from "../tasks/DetachDraftsFromCollectionTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class CollectionDeletedProcessor extends BaseProcessor {
|
||||
static applicableEvents: TEvent["name"][] = ["collections.delete"];
|
||||
export default class CollectionsProcessor extends BaseProcessor {
|
||||
static applicableEvents: TEvent["name"][] = [
|
||||
"collections.delete",
|
||||
"collections.archive",
|
||||
];
|
||||
|
||||
async perform(event: CollectionEvent) {
|
||||
await DetachDraftsFromCollectionTask.schedule({
|
||||
@@ -852,6 +852,8 @@ export default class WebsocketsProcessor {
|
||||
channels.push(
|
||||
...this.getCollectionEventChannels(event, document.collection)
|
||||
);
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
channels.push(`team-${document.teamId}`);
|
||||
} else {
|
||||
channels.push(`collection-${document.collectionId}`);
|
||||
}
|
||||
|
||||
@@ -27,4 +27,28 @@ describe("DetachDraftsFromCollectionTask", () => {
|
||||
expect(draft?.deletedAt).toBe(null);
|
||||
expect(draft?.collectionId).toBe(null);
|
||||
});
|
||||
|
||||
it("should detach drafts from archived collection", async () => {
|
||||
const collection = await buildCollection({ archivedAt: new Date() });
|
||||
const document = await buildDocument({
|
||||
title: "test",
|
||||
collectionId: collection.id,
|
||||
publishedAt: null,
|
||||
createdById: collection.createdById,
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
|
||||
const task = new DetachDraftsFromCollectionTask();
|
||||
await task.perform({
|
||||
collectionId: collection.id,
|
||||
ip,
|
||||
actorId: collection.createdById,
|
||||
});
|
||||
|
||||
const draft = await Document.findByPk(document.id);
|
||||
expect(draft).not.toBe(null);
|
||||
expect(draft?.archivedAt).toBe(null);
|
||||
expect(draft?.deletedAt).toBe(null);
|
||||
expect(draft?.collectionId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,11 @@ export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
|
||||
User.findByPk(props.actorId),
|
||||
]);
|
||||
|
||||
if (!actor || !collection || !collection.deletedAt) {
|
||||
if (
|
||||
!actor ||
|
||||
!collection ||
|
||||
!(collection.deletedAt || collection.archivedAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SourceMetadata } from "@shared/types";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import { User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import FileStorage from "@server/storage/files";
|
||||
@@ -48,8 +49,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
||||
fileName: sourceMetadata.fileName,
|
||||
mimeType: sourceMetadata.mimeType,
|
||||
content,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction, ip),
|
||||
});
|
||||
|
||||
return documentCreator({
|
||||
@@ -62,8 +62,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction, ip),
|
||||
});
|
||||
});
|
||||
return { documentId: document.id };
|
||||
|
||||
@@ -40,7 +40,7 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
await Notification.create({
|
||||
event: NotificationEventType.MentionedInDocument,
|
||||
userId: recipient.id,
|
||||
actorId: document.updatedBy.id,
|
||||
actorId: mention.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export default class ErrorTimedOutFileOperationsTask extends BaseTask<Props> {
|
||||
fileOperations.map(async (fileOperation) => {
|
||||
fileOperation.state = FileOperationState.Error;
|
||||
fileOperation.error = "Timed out";
|
||||
await fileOperation.save();
|
||||
await fileOperation.save({ hooks: false });
|
||||
})
|
||||
);
|
||||
Logger.info("task", `Updated ${fileOperations.length} file operations`);
|
||||
|
||||
@@ -157,12 +157,17 @@ export default abstract class ExportTask extends BaseTask<Props> {
|
||||
fileOperation: FileOperation,
|
||||
options: Partial<FileOperation> & { error?: Error }
|
||||
) {
|
||||
await fileOperation.update({
|
||||
...options,
|
||||
error: options.error
|
||||
? truncate(options.error.message, { length: 255 })
|
||||
: undefined,
|
||||
});
|
||||
await fileOperation.update(
|
||||
{
|
||||
...options,
|
||||
error: options.error
|
||||
? truncate(options.error.message, { length: 255 })
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.schedule({
|
||||
name: "fileOperations.update",
|
||||
|
||||
@@ -4,9 +4,11 @@ import escapeRegExp from "lodash/escapeRegExp";
|
||||
import mime from "mime-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation, User } from "@server/models";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
|
||||
import ImportTask, { StructuredImportData } from "./ImportTask";
|
||||
|
||||
@@ -82,16 +84,19 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, icon, text } = await documentImporter({
|
||||
mimeType: "text/markdown",
|
||||
fileName: child.name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ip: user.lastActiveIp || undefined,
|
||||
});
|
||||
const { title, icon, text } = await sequelize.transaction(
|
||||
async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType: "text/markdown",
|
||||
fileName: child.name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
const existingDocumentIndex = output.documents.findIndex(
|
||||
(doc) =>
|
||||
|
||||
@@ -5,8 +5,10 @@ import escapeRegExp from "lodash/escapeRegExp";
|
||||
import mime from "mime-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
|
||||
import ImportTask, { StructuredImportData } from "./ImportTask";
|
||||
|
||||
@@ -22,17 +24,45 @@ export default class ImportNotionTask extends ImportTask {
|
||||
|
||||
// New Notion exports have a single folder with the name of the export, we must skip this
|
||||
// folder and go directly to the children.
|
||||
let parsed;
|
||||
if (
|
||||
tree.children.length === 1 &&
|
||||
tree.children[0].children.find((child) => child.title === "index")
|
||||
) {
|
||||
return this.parseFileTree(
|
||||
parsed = await this.parseFileTree(
|
||||
fileOperation,
|
||||
tree.children[0].children.filter((child) => child.title !== "index")
|
||||
);
|
||||
} else {
|
||||
parsed = await this.parseFileTree(fileOperation, tree.children);
|
||||
}
|
||||
|
||||
return this.parseFileTree(fileOperation, tree.children);
|
||||
if (parsed.documents.length === 0 && parsed.collections.length === 1) {
|
||||
const collection = parsed.collections[0];
|
||||
const collectionId = uuidv4();
|
||||
if (collection.description) {
|
||||
parsed.documents.push({
|
||||
title: collection.name,
|
||||
icon: collection.icon,
|
||||
color: collection.color,
|
||||
path: "",
|
||||
text: String(collection.description),
|
||||
id: collection.id,
|
||||
externalId: collection.externalId,
|
||||
mimeType: "text/html",
|
||||
collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
collection.name = "Notion";
|
||||
collection.icon = undefined;
|
||||
collection.color = undefined;
|
||||
collection.externalId = undefined;
|
||||
collection.description = undefined;
|
||||
collection.id = collectionId;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,16 +126,19 @@ export default class ImportNotionTask extends ImportTask {
|
||||
|
||||
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
||||
|
||||
const { title, icon, text } = await documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ip: user.lastActiveIp || undefined,
|
||||
});
|
||||
const { title, icon, text } = await sequelize.transaction(
|
||||
async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
const existingDocumentIndex = output.documents.findIndex(
|
||||
(doc) => doc.externalId === externalId
|
||||
@@ -218,13 +251,15 @@ export default class ImportNotionTask extends ImportTask {
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/html"
|
||||
) {
|
||||
const { text } = await documentImporter({
|
||||
mimeType,
|
||||
fileName: name,
|
||||
content: await fs.readFile(node.path, "utf8"),
|
||||
user,
|
||||
ip: user.lastActiveIp || undefined,
|
||||
});
|
||||
const { text } = await sequelize.transaction(async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType,
|
||||
fileName: name,
|
||||
content: await fs.readFile(node.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext(user, transaction),
|
||||
})
|
||||
);
|
||||
|
||||
description = text;
|
||||
} else if (node.children.length > 0) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import attachmentCreator from "@server/commands/attachmentCreator";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { serializer } from "@server/editor";
|
||||
import { InternalError, ValidationError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -183,10 +184,15 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
state: FileOperationState,
|
||||
error?: Error
|
||||
) {
|
||||
await fileOperation.update({
|
||||
state,
|
||||
error: error ? truncate(error.message, { length: 255 }) : undefined,
|
||||
});
|
||||
await fileOperation.update(
|
||||
{
|
||||
state,
|
||||
error: error ? truncate(error.message, { length: 255 }) : undefined,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
}
|
||||
);
|
||||
await Event.schedule({
|
||||
name: "fileOperations.update",
|
||||
modelId: fileOperation.id,
|
||||
@@ -468,8 +474,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
parentDocumentId: item.parentDocumentId,
|
||||
importId: fileOperation.id,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction),
|
||||
});
|
||||
documents.set(item.id, document);
|
||||
|
||||
@@ -503,8 +508,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
type: item.mimeType,
|
||||
buffer: await item.buffer(),
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
ctx: createContext(user, transaction),
|
||||
});
|
||||
if (attachment) {
|
||||
attachments.set(item.id, attachment);
|
||||
|
||||
@@ -60,7 +60,7 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
event: NotificationEventType.MentionedInDocument,
|
||||
userId: recipient.id,
|
||||
revisionId: event.modelId,
|
||||
actorId: document.updatedBy.id,
|
||||
actorId: mention.actorId,
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { ApiKey, Event, User } from "@server/models";
|
||||
import { ApiKey, User } from "@server/models";
|
||||
import { authorize, cannot } from "@server/policies";
|
||||
import { presentApiKey } from "@server/presenters";
|
||||
import { APIContext, AuthenticationType } from "@server/types";
|
||||
@@ -21,28 +21,17 @@ router.post(
|
||||
async (ctx: APIContext<T.APIKeysCreateReq>) => {
|
||||
const { name, expiresAt } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
authorize(user, "createApiKey", user.team);
|
||||
const key = await ApiKey.create(
|
||||
{
|
||||
name,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "api_keys.create",
|
||||
modelId: key.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
const apiKey = await ApiKey.createWithCtx(ctx, {
|
||||
name,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentApiKey(key),
|
||||
data: presentApiKey(apiKey),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -54,6 +43,7 @@ router.post(
|
||||
validate(T.APIKeysListSchema),
|
||||
async (ctx: APIContext<T.APIKeysListReq>) => {
|
||||
const { userId } = ctx.input.body;
|
||||
const { pagination } = ctx.state;
|
||||
const actor = ctx.state.auth.user;
|
||||
|
||||
let where: WhereOptions<User> = {
|
||||
@@ -77,7 +67,7 @@ router.post(
|
||||
};
|
||||
}
|
||||
|
||||
const keys = await ApiKey.findAll({
|
||||
const apiKeys = await ApiKey.findAll({
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
@@ -86,13 +76,13 @@ router.post(
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
offset: pagination.offset,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: keys.map(presentApiKey),
|
||||
pagination,
|
||||
data: apiKeys.map(presentApiKey),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -113,14 +103,7 @@ router.post(
|
||||
});
|
||||
authorize(user, "delete", key);
|
||||
|
||||
await key.destroy({ transaction });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "api_keys.delete",
|
||||
modelId: key.id,
|
||||
data: {
|
||||
name: key.name,
|
||||
},
|
||||
});
|
||||
await key.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -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 { Attachment, Document, Event } from "@server/models";
|
||||
import { Attachment, Document } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentAttachment } from "@server/presenters";
|
||||
@@ -64,26 +64,16 @@ router.post(
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const attachment = await Attachment.create(
|
||||
{
|
||||
id: modelId,
|
||||
key,
|
||||
acl,
|
||||
size,
|
||||
expiresAt: AttachmentHelper.presetToExpiry(preset),
|
||||
contentType,
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "attachments.create",
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
modelId,
|
||||
const attachment = await Attachment.createWithCtx(ctx, {
|
||||
id: modelId,
|
||||
key,
|
||||
acl,
|
||||
size,
|
||||
expiresAt: AttachmentHelper.presetToExpiry(preset),
|
||||
contentType,
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const presignedPost = await FileStorage.getPresignedPost(
|
||||
@@ -139,10 +129,7 @@ router.post(
|
||||
}
|
||||
|
||||
authorize(user, "delete", attachment);
|
||||
await attachment.destroy({ transaction });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "attachments.delete",
|
||||
});
|
||||
await attachment.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -165,7 +165,6 @@ router.post(
|
||||
async (ctx: APIContext<T.CollectionsImportReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { attachmentId, permission, format } = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "importCollection", user.team);
|
||||
|
||||
@@ -174,29 +173,16 @@ router.post(
|
||||
});
|
||||
authorize(user, "read", attachment);
|
||||
|
||||
const fileOperation = await FileOperation.create(
|
||||
{
|
||||
type: FileOperationType.Import,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
size: attachment.size,
|
||||
key: attachment.key,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
permission,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "fileOperations.create",
|
||||
modelId: fileOperation.id,
|
||||
data: {
|
||||
type: FileOperationType.Import,
|
||||
await FileOperation.createWithCtx(ctx, {
|
||||
type: FileOperationType.Import,
|
||||
state: FileOperationState.Creating,
|
||||
format,
|
||||
size: attachment.size,
|
||||
key: attachment.key,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
permission,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -560,8 +546,8 @@ router.post(
|
||||
validate(T.CollectionsExportSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsExportReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id, format, includeAttachments } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const team = await Team.findByPk(user.teamId, { transaction });
|
||||
@@ -578,8 +564,7 @@ router.post(
|
||||
team,
|
||||
format,
|
||||
includeAttachments,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
ctx,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -598,9 +583,9 @@ router.post(
|
||||
validate(T.CollectionsExportAllSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsExportAllReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { format, includeAttachments } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId, { transaction });
|
||||
authorize(user, "createExport", team);
|
||||
|
||||
@@ -609,8 +594,7 @@ router.post(
|
||||
team,
|
||||
format,
|
||||
includeAttachments,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
ctx,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -2907,13 +2907,19 @@ describe("#documents.restore", () => {
|
||||
expect(body.message).toEqual("collectionId: Invalid uuid");
|
||||
});
|
||||
|
||||
it("should allow restore of trashed documents", async () => {
|
||||
it("should allow restore of trashed drafts of a collection", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDraftDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await document.destroy();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
@@ -2921,17 +2927,46 @@ describe("#documents.restore", () => {
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of trashed drafts without collection", async () => {
|
||||
it("should allow restore of trashed drafts with collectionId", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDraftDocument({
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await document.delete(user);
|
||||
const document = await buildDraftDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: null,
|
||||
});
|
||||
await document.destroy();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of trashed documents", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await document.destroy();
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
@@ -2967,7 +3002,127 @@ describe("#documents.restore", () => {
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should not allow restore of documents in deleted collection", async () => {
|
||||
it("should allow restore of documents from a deleted collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const anotherCollection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await document.delete(user);
|
||||
await collection.destroy({ hooks: false });
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: anotherCollection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.archivedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(anotherCollection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of collection templates", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const template = await buildDocument({
|
||||
template: true,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await template.delete(user);
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of templates from a deleted collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const anotherCollection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const template = await buildDocument({
|
||||
template: true,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await template.delete(user);
|
||||
await collection.destroy({ hooks: false });
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
collectionId: anotherCollection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(anotherCollection.id);
|
||||
});
|
||||
|
||||
it("should allow restore of workspace templates", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const template = await buildDocument({
|
||||
template: true,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
collectionId: null,
|
||||
});
|
||||
await template.delete(user);
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: template.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(null);
|
||||
});
|
||||
|
||||
it("should not allow restore of documents to a deleted collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -2979,9 +3134,9 @@ describe("#documents.restore", () => {
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
await document.destroy();
|
||||
await document.delete(user);
|
||||
await collection.destroy({ hooks: false });
|
||||
// passing deleted collection's id
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
@@ -2989,26 +3144,15 @@ describe("#documents.restore", () => {
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
// not passing collection's id
|
||||
const anotherRes = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const anotherBody = await anotherRes.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
expect(anotherRes.status).toEqual(400);
|
||||
expect(anotherBody.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow restore of documents in archived collection", async () => {
|
||||
it("should not allow restore of documents to an archived collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
@@ -3021,6 +3165,7 @@ describe("#documents.restore", () => {
|
||||
await document.destroy();
|
||||
collection.archivedAt = new Date();
|
||||
await collection.save();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
@@ -3029,6 +3174,7 @@ describe("#documents.restore", () => {
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
|
||||
@@ -819,7 +819,7 @@ router.post(
|
||||
const srcCollection = sourceCollectionId
|
||||
? await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(sourceCollectionId)
|
||||
}).findByPk(sourceCollectionId, { paranoid: false })
|
||||
: undefined;
|
||||
|
||||
const destCollection = destCollectionId
|
||||
@@ -828,13 +828,15 @@ router.post(
|
||||
}).findByPk(destCollectionId)
|
||||
: undefined;
|
||||
|
||||
if (!destCollection?.isActive) {
|
||||
// In case of workspace templates, both source and destination collections are undefined.
|
||||
if (!document.isWorkspaceTemplate && !destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceCollectionId !== destCollectionId) {
|
||||
// Skip this for workspace templates and drafts of a deleted collection as they won't have sourceCollectionId.
|
||||
if (sourceCollectionId && sourceCollectionId !== destCollectionId) {
|
||||
authorize(user, "updateDocument", srcCollection);
|
||||
await srcCollection?.removeDocumentInStructure(document, {
|
||||
save: true,
|
||||
@@ -842,7 +844,19 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt) {
|
||||
if (document.deletedAt && document.isWorkspaceTemplate) {
|
||||
authorize(user, "restore", document);
|
||||
|
||||
await document.restore({ transaction });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.restore",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
} else if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
@@ -1272,10 +1286,9 @@ router.post(
|
||||
document,
|
||||
title,
|
||||
publish,
|
||||
transaction,
|
||||
recursive,
|
||||
parentDocumentId,
|
||||
ip: ctx.request.ip,
|
||||
ctx,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -1534,7 +1547,6 @@ router.post(
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
publish,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
const response: DocumentImportTaskResponse = await job.finished();
|
||||
if ("error" in response) {
|
||||
@@ -1629,12 +1641,7 @@ router.post(
|
||||
|
||||
const document = await documentCreator({
|
||||
title,
|
||||
text: await TextHelper.replaceImagesWithAttachments(
|
||||
text,
|
||||
user,
|
||||
ctx.request.ip,
|
||||
transaction
|
||||
),
|
||||
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
|
||||
icon,
|
||||
color,
|
||||
createdAt,
|
||||
@@ -1646,8 +1653,7 @@ router.post(
|
||||
fullWidth,
|
||||
user,
|
||||
editorVersion,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
ctx,
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
|
||||
@@ -282,6 +282,7 @@ describe("#fileOperations.delete", () => {
|
||||
expect(
|
||||
await Event.count({
|
||||
where: {
|
||||
name: "fileOperations.delete",
|
||||
teamId: team.id,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Router from "koa-router";
|
||||
import { WhereOptions } from "sequelize";
|
||||
import { UserRole } from "@shared/types";
|
||||
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -116,15 +115,11 @@ router.post(
|
||||
const fileOperation = await FileOperation.unscoped().findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "delete", fileOperation);
|
||||
|
||||
await fileOperationDeleter({
|
||||
fileOperation,
|
||||
user,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
await fileOperation.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -51,6 +51,7 @@ export const SharesUpdateSchema = BaseSchema.extend({
|
||||
id: z.string().uuid(),
|
||||
includeChildDocuments: z.boolean().optional(),
|
||||
published: z.boolean().optional(),
|
||||
allowIndexing: z.boolean().optional(),
|
||||
urlId: z
|
||||
.string()
|
||||
.regex(UrlHelper.SHARE_URL_SLUG_REGEX, {
|
||||
|
||||
@@ -230,7 +230,8 @@ router.post(
|
||||
auth(),
|
||||
validate(T.SharesUpdateSchema),
|
||||
async (ctx: APIContext<T.SharesUpdateReq>) => {
|
||||
const { id, includeChildDocuments, published, urlId } = ctx.input.body;
|
||||
const { id, includeChildDocuments, published, urlId, allowIndexing } =
|
||||
ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "share", user.team);
|
||||
@@ -257,6 +258,10 @@ router.post(
|
||||
share.urlId = urlId;
|
||||
}
|
||||
|
||||
if (allowIndexing !== undefined) {
|
||||
share.allowIndexing = allowIndexing;
|
||||
}
|
||||
|
||||
await share.save();
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "shares.update",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Router from "koa-router";
|
||||
import { Sequelize } from "sequelize";
|
||||
import starCreator from "@server/commands/starCreator";
|
||||
import starDestroyer from "@server/commands/starDestroyer";
|
||||
import starUpdater from "@server/commands/starUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
@@ -46,12 +44,11 @@ router.post(
|
||||
}
|
||||
|
||||
const star = await starCreator({
|
||||
ctx,
|
||||
user,
|
||||
documentId,
|
||||
collectionId,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -130,19 +127,13 @@ router.post(
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let star = await Star.findByPk(id, {
|
||||
const star = await Star.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "update", star);
|
||||
|
||||
star = await starUpdater({
|
||||
user,
|
||||
star,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
transaction,
|
||||
});
|
||||
await star.updateWithCtx(ctx, { index });
|
||||
|
||||
ctx.body = {
|
||||
data: presentStar(star),
|
||||
@@ -167,7 +158,7 @@ router.post(
|
||||
});
|
||||
authorize(user, "delete", star);
|
||||
|
||||
await starDestroyer({ user, star, ip: ctx.request.ip, transaction });
|
||||
await star.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
+10
-2
@@ -54,6 +54,7 @@ export const renderApp = async (
|
||||
rootShareId?: string;
|
||||
isShare?: boolean;
|
||||
analytics?: Integration<IntegrationType.Analytics>[];
|
||||
allowIndexing?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const {
|
||||
@@ -61,6 +62,7 @@ export const renderApp = async (
|
||||
description = "A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, & more…",
|
||||
canonical = "",
|
||||
shortcutIcon = `${env.CDN_URL || ""}/images/favicon-32.png`,
|
||||
allowIndexing = true,
|
||||
} = options;
|
||||
|
||||
if (ctx.request.path === "/realtime/") {
|
||||
@@ -91,6 +93,10 @@ export const renderApp = async (
|
||||
</script>
|
||||
`;
|
||||
|
||||
const noIndexTag = allowIndexing
|
||||
? ""
|
||||
: '<meta name="robots" content="noindex, nofollow">';
|
||||
|
||||
const scriptTags = env.isProduction
|
||||
? `<script type="module" nonce="${ctx.state.cspNonce}" src="${
|
||||
env.CDN_URL || ""
|
||||
@@ -112,6 +118,7 @@ export const renderApp = async (
|
||||
.replace(/\{lang\}/g, unicodeCLDRtoISO639(env.DEFAULT_LANGUAGE))
|
||||
.replace(/\{title\}/g, escape(title))
|
||||
.replace(/\{description\}/g, escape(description))
|
||||
.replace(/\{noindex\}/g, noIndexTag)
|
||||
.replace(
|
||||
/\{manifest-url\}/g,
|
||||
options.isShare ? "" : "/static/manifest.webmanifest"
|
||||
@@ -131,8 +138,8 @@ export const renderShare = async (ctx: Context, next: Next) => {
|
||||
const documentSlug = ctx.params.documentSlug;
|
||||
|
||||
// Find the share record if publicly published so that the document title
|
||||
// can be be returned in the server-rendered HTML. This allows it to appear in
|
||||
// unfurls with more reliablity
|
||||
// can be returned in the server-rendered HTML. This allows it to appear in
|
||||
// unfurls with more reliability
|
||||
let share, document, team;
|
||||
let analytics: Integration<IntegrationType.Analytics>[] = [];
|
||||
|
||||
@@ -188,5 +195,6 @@ export const renderShare = async (ctx: Context, next: Next) => {
|
||||
canonical: share
|
||||
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
|
||||
: undefined,
|
||||
allowIndexing: share?.allowIndexing,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,5 +151,100 @@ ${resizeObserverScript(ctx)}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.host.endsWith("pinterest.com") &&
|
||||
parsed.protocol === "https:" &&
|
||||
ctx.path === "/embeds/pinterest"
|
||||
) {
|
||||
const pinterestJs = "https://assets.pinterest.com/js/pinit.js";
|
||||
const csp = ctx.response.get("Content-Security-Policy");
|
||||
|
||||
const pathParts = parsed.pathname.split("/").filter(Boolean);
|
||||
const isProfile =
|
||||
pathParts.length === 1 ||
|
||||
(pathParts.length === 2 && pathParts[1].startsWith("_"));
|
||||
const pinType = isProfile ? "embedUser" : "embedBoard";
|
||||
|
||||
ctx.set(
|
||||
"Content-Security-Policy",
|
||||
csp
|
||||
.replace(
|
||||
"script-src",
|
||||
"script-src assets.pinterest.com widgets.pinterest.com"
|
||||
)
|
||||
.replace(
|
||||
"style-src",
|
||||
"style-src assets.pinterest.com widgets.pinterest.com"
|
||||
)
|
||||
);
|
||||
ctx.set("X-Frame-Options", "sameorigin");
|
||||
|
||||
ctx.type = "html";
|
||||
ctx.body = `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
html, body, iframe {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
.pinterest-container {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pinterest-container > span {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.pinterest-container iframe {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
span[class*="_bd"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pinterest-container > span {
|
||||
border-color: rgb(35, 38, 41) !important;
|
||||
background-color: rgb(22, 25, 28) !important;
|
||||
}
|
||||
|
||||
[class$="_pinner"],
|
||||
[class$="_board"] {
|
||||
color: #e6e6e6 !important;
|
||||
}
|
||||
[class$="_button"] {
|
||||
border-color: rgb(38, 42, 50) !important;
|
||||
background-color: rgba(3, 58, 120, 0.1) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<base target="_parent">
|
||||
${iframeCheckScript(ctx)}
|
||||
</head>
|
||||
<body>
|
||||
<div class="pinterest-container">
|
||||
<a
|
||||
data-pin-do="${pinType}"
|
||||
data-pin-board-width="100%"
|
||||
href="${url}"
|
||||
style="width:100%;max-width:none;"
|
||||
></a>
|
||||
</div>
|
||||
<script type="text/javascript" async defer src="${pinterestJs}"></script>
|
||||
${resizeObserverScript(ctx)}
|
||||
</body>
|
||||
</html>`;
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -132,6 +132,7 @@ router.get("/s/:shareId/*", shareDomains(), renderShare);
|
||||
router.get("/embeds/gitlab", renderEmbed);
|
||||
router.get("/embeds/github", renderEmbed);
|
||||
router.get("/embeds/dropbox", renderEmbed);
|
||||
router.get("/embeds/pinterest", renderEmbed);
|
||||
|
||||
// catch all for application
|
||||
router.get("*", shareDomains(), async (ctx, next) => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="description" content="{description}" />
|
||||
<meta name="darkreader-lock" />
|
||||
{noindex}
|
||||
<link rel="manifest" href="{manifest-url}" />
|
||||
<link rel="canonical" href="{canonical-url}" data-react-helmet="true" />
|
||||
{prefetch}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { Transaction } from "sequelize";
|
||||
import sharedEnv from "@shared/env";
|
||||
import { createContext } from "@server/context";
|
||||
import env from "@server/env";
|
||||
import { User } from "@server/models";
|
||||
import onerror from "@server/onerror";
|
||||
@@ -45,6 +46,7 @@ export function withAPIContext<T>(
|
||||
transaction,
|
||||
};
|
||||
return fn({
|
||||
...createContext(user, transaction),
|
||||
state,
|
||||
request: {
|
||||
ip: faker.internet.ip(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user