Compare commits

...

30 Commits

Author SHA1 Message Date
tommoor 8b92d71a61 chore: Compressed inefficient images automatically 2024-11-14 01:46:40 +00:00
Benjamin Kramser cfce55250e Add Pinterest embed (#7930)
* add pinterest embed

* improved profile detection
2024-11-13 17:46:07 -08:00
Tom Moor 6421995b29 fix: Do not override from address in self-hosted env, closes #7929 2024-11-13 20:19:17 -05:00
Tom Moor 8cfd8e25db fix: Event should not be written when API key is used 2024-11-13 09:10:30 -05:00
Tom Moor 1282e9653e fix: Excess padding on internal read-only docs, should only have applied to shares 2024-11-13 08:16:06 -05:00
Tom Moor f1edaecf49 perf: Fix observable changing on every keydown 2024-11-13 08:16:06 -05:00
Tom Moor f7d737ca45 fix: Missing 'Untitled' in reference list 2024-11-13 08:16:06 -05:00
Tom Moor 41c2c760d4 v0.81.0 2024-11-13 08:16:06 -05:00
dependabot[bot] f692d1bc3a chore(deps-dev): bump terser from 5.32.0 to 5.36.0 (#7928)
Bumps [terser](https://github.com/terser/terser) from 5.32.0 to 5.36.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.32.0...v5.36.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:06:33 -08:00
dependabot[bot] 5197d6e18c chore(deps): bump prosemirror-view from 1.34.3 to 1.36.0 (#7925)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.34.3 to 1.36.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.34.3...1.36.0)

---
updated-dependencies:
- dependency-name: prosemirror-view
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:06:16 -08:00
dependabot[bot] b901ea7b30 chore(deps-dev): bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 (#7924)
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.0 to 4.6.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:21 -08:00
dependabot[bot] 3820499856 chore(deps-dev): bump @types/jest from 29.5.13 to 29.5.14 (#7927)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.13 to 29.5.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

---
updated-dependencies:
- dependency-name: "@types/jest"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:11 -08:00
dependabot[bot] 0cffde63ab chore(deps): bump zod from 3.22.4 to 3.23.8 (#7926)
Bumps [zod](https://github.com/colinhacks/zod) from 3.22.4 to 3.23.8.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/main/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.22.4...v3.23.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:01 -08:00
Hemachandar 449ba6488e hide share option for templates (#7937) 2024-11-13 05:03:36 -08:00
Tom Moor 62f3e6921f Add changes property to client event model 2024-11-10 22:58:51 -05:00
Tom Moor bc259316f7 Include changes in event presenter 2024-11-10 22:35:11 -05:00
Tom Moor 98e03cc227 Convert stars, towards #7920 (#7921) 2024-11-10 19:26:27 -08:00
Tom Moor 633e547d3e Refactor of event insertion (#5909) 2024-11-10 16:26:20 -08:00
Tom Moor d5de69fd4b fix: Exception for Notion import of a single document 2024-11-09 19:26:44 -05:00
Hemachandar feec01f160 fix: don't show comment marks for other users' drafts (#7838)
* fix: don't show comment marks for other users' drafts

* remove unnecessary draft check
2024-11-09 10:43:59 -08:00
Tom Moor aa5813032e fix: Click image to focus 2024-11-09 13:02:55 -05:00
Tom Moor a6ba189180 Add menu item to leave document that has been shared with current user (#7918)
* Add menu item to leave document that has been shared with current user

* Only redirect if viewing doc
2024-11-09 06:45:59 -08:00
Tom Moor 4c65bbc57c fix: Improved toolbar behavior with partial link selection, closes #7890 2024-11-08 22:41:58 -05:00
Tom Moor c76b4f46aa Tweak sharing UI 2024-11-08 21:35:55 -05:00
infinite-persistence ca17b41c53 share: add allowIndexing (#7896)
* share: add `allowIndexing`

## Ticket
Closes 7486

* i18n: follow existing no-punctuation style
2024-11-08 17:28:30 -08:00
Tom Moor 9747c6ba5d fix: Document mentions can be incorrectly attributed during collab session (#7913) 2024-11-08 05:35:49 -08:00
Tom Moor 55ffd6d098 feat: Adds support for importing CSV files (#7912)
* feat: Adds support for importing CSV files

* test

* tsc
2024-11-07 19:09:02 -08:00
Tom Moor 9b26ccda19 fix: Switching edit mode scrolls to page top, closes #7910 2024-11-07 22:04:15 -05:00
Hemachandar 56b38b9dbd fix: restore documents from a deleted collection (#7909) 2024-11-07 18:03:30 -08:00
Hemachandar 0a3a684493 fix: collection archival post-process parity with deletion (#7906) 2024-11-07 18:02:51 -08:00
90 changed files with 1368 additions and 769 deletions
+2 -2
View File
@@ -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
+39
View File
@@ -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";
@@ -1119,6 +1121,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 +1175,7 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
+9 -8
View File
@@ -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>
);
+1 -1
View File
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
+5 -3
View File
@@ -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}
@@ -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")}&nbsp;
<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}
+7 -7
View File
@@ -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}
+4 -1
View File
@@ -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;
-1
View File
@@ -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),
},
+2
View File
@@ -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 && (
+18 -11
View File
@@ -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;
+4
View File
@@ -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;
+12 -5
View File
@@ -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}
@@ -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,
+2 -1
View File
@@ -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>
);
+10 -6
View File
@@ -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 {
+1
View File
@@ -63,6 +63,7 @@ export default class DocumentsStore extends Store<Document> {
".md",
".doc",
".docx",
"text/csv",
"text/markdown",
"text/plain",
"text/html",
+2 -2
View File
@@ -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");
}
}
+7 -6
View File
@@ -68,6 +68,7 @@
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
@@ -183,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",
@@ -247,7 +248,7 @@
"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",
@@ -269,7 +270,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",
@@ -340,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",
@@ -353,7 +354,7 @@
"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"
@@ -367,5 +368,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

+23 -53
View File
@@ -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;
}
+17 -42
View File
@@ -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;
+4 -5
View File
@@ -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) {
+5 -10
View File
@@ -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),
})
);
+7 -12
View File
@@ -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) {
+123 -94
View File
@@ -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```");
});
});
+4 -11
View File
@@ -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) {
-30
View File
@@ -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,
}
);
}
+6 -10
View File
@@ -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,
})
);
+8 -26
View File
@@ -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;
}
-40
View File
@@ -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);
});
});
-43
View File
@@ -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;
}
-37
View File
@@ -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);
});
});
-47
View File
@@ -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;
}
+17
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+10
View File
@@ -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");
},
};
+7 -1
View File
@@ -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 });
};
}
+4
View File
@@ -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
+1
View File
@@ -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;
+2
View File
@@ -51,6 +51,8 @@ class FileOperation extends ParanoidModel<
InferAttributes<FileOperation>,
Partial<InferCreationAttributes<FileOperation>>
> {
static eventNamespace = "fileOperations";
@Column(DataType.ENUM(...Object.values(FileOperationType)))
type: FileOperationType;
+4
View File
@@ -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;
+2
View File
@@ -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`,
+1
View File
@@ -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
View File
@@ -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;
+23
View File
@@ -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) || [];
}
+5 -8
View File
@@ -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) {
+13 -1
View File
@@ -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(
+2
View File
@@ -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;
}
+1
View File
@@ -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,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({
@@ -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;
}
+3 -4
View File
@@ -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`);
+11 -6
View File
@@ -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",
+15 -10
View File
@@ -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) =>
+54 -19
View File
@@ -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) {
+12 -8
View File
@@ -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,
});
+13 -30
View File
@@ -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,
+12 -25
View File
@@ -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,
+14 -30
View File
@@ -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 = {
+81 -21
View File
@@ -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,41 @@ 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 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 +3048,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 +3058,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 +3079,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 +3088,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"
+6 -13
View File
@@ -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
@@ -834,7 +834,8 @@ router.post(
);
}
if (sourceCollectionId !== destCollectionId) {
// Skip this for 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,
@@ -1272,10 +1273,9 @@ router.post(
document,
title,
publish,
transaction,
recursive,
parentDocumentId,
ip: ctx.request.ip,
ctx,
});
ctx.body = {
@@ -1534,7 +1534,6 @@ router.post(
collectionId,
parentDocumentId,
publish,
ip: ctx.request.ip,
});
const response: DocumentImportTaskResponse = await job.finished();
if ("error" in response) {
@@ -1629,12 +1628,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 +1640,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,
+1
View File
@@ -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, {
+6 -1
View File
@@ -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",
+4 -13
View File
@@ -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
View File
@@ -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, &amp; 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,
});
};
+95
View File
@@ -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();
};
+1
View File
@@ -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) => {
+1
View File
@@ -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}
+2
View File
@@ -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(),
+10 -3
View File
@@ -49,8 +49,8 @@ export type AuthenticationResult = AccountProvisionerResult & {
export type Authentication = {
user: User;
token: string;
type: AuthenticationType;
token?: string;
type?: AuthenticationType;
};
export type Pagination = {
@@ -77,8 +77,15 @@ export interface APIContext<ReqT = BaseReq, ResT = BaseRes>
DefaultContext & IRouterParamContext<AppState>,
ResT
> {
/** Typed and validated version of request, consisting of validated body, query, etc */
/** Typed and validated version of request, consisting of validated body, query, etc. */
input: ReqT;
/** The current request's context, which is passed to database mutations. */
context: {
transaction?: Transaction;
auth: Authentication;
ip?: string;
};
}
type BaseEvent<T extends Model> = {
+31
View File
@@ -0,0 +1,31 @@
import { DocumentConverter } from "./DocumentConverter";
describe("csvToMarkdown", () => {
it("should convert csv to markdown with comma", async () => {
const csv = `name,age
John,25
Jane,24`;
const markdown = `| name | age |
| --- | --- |
| John | 25 |
| Jane | 24 |
`;
expect(await DocumentConverter.csvToMarkdown(csv)).toEqual(markdown);
});
it("should convert csv to markdown with semicolon", async () => {
const csv = `name;age
John;25
"Joan ""the bone"", Anne";24`;
const markdown = `| name | age |
| --- | --- |
| John | 25 |
| Joan "the bone", Anne | 24 |
`;
expect(await DocumentConverter.csvToMarkdown(csv)).toEqual(markdown);
});
});
+46 -1
View File
@@ -1,3 +1,4 @@
import { parse } from "@fast-csv/parse";
import escapeRegExp from "lodash/escapeRegExp";
import { simpleParser } from "mailparser";
import mammoth from "mammoth";
@@ -30,6 +31,8 @@ export class DocumentConverter {
case "text/plain":
case "text/markdown":
return this.fileToMarkdown(content);
case "text/csv":
return this.csvToMarkdown(content);
default:
break;
}
@@ -71,7 +74,49 @@ export class DocumentConverter {
return turndownService.turndown(content);
}
public static async fileToMarkdown(content: Buffer | string) {
public static csvToMarkdown(content: Buffer | string): Promise<string> {
return new Promise((resolve, reject) => {
const text = this.fileToMarkdown(content).trim();
const firstLine = text.split("\n")[0];
// Determine the separator used in the CSV file based on number of occurrences of each separator on first line
const delimiter = [";", ",", "\t"].reduce(
(acc, separator) => {
const count = (
firstLine.match(new RegExp(escapeRegExp(separator), "g")) || []
).length;
return count > acc.count ? { count, separator } : acc;
},
{ count: 0, separator: "," }
).separator;
const lines: string[][] = [];
const stream = parse({ delimiter })
.on("error", (error) => {
reject(
FileImportError(`There was an error parsing the CSV file: ${error}`)
);
})
.on("data", (row) => lines.push(row))
.on("end", () => {
const headers = lines[0];
const table = lines
.slice(1)
.map((cells) => `| ${cells.join(" | ")} |`)
.join("\n");
const headerLine = `| ${headers.join(" | ")} |`;
const separatorLine = `| ${headers.map(() => "---").join(" | ")} |`;
resolve(`${headerLine}\n${separatorLine}\n${table}\n`);
});
stream.write(text);
stream.end();
});
}
public static fileToMarkdown(content: Buffer | string) {
if (content instanceof Buffer) {
content = content.toString("utf8");
}
+25 -2
View File
@@ -3,16 +3,39 @@ import * as React from "react";
type Props = {
children?: React.ReactNode;
className?: string;
/**
* Capture all events, pointer events, or click events.
* @default "all"
*/
captureEvents?: "all" | "pointer" | "click";
};
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
const EventBoundary: React.FC<Props> = ({
children,
className,
captureEvents = "all",
}: Props) => {
const stopEvent = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
let props = {};
if (captureEvents === "all" || captureEvents === "pointer") {
props = {
onPointerDown: stopEvent,
onPointerUp: stopEvent,
};
}
if (captureEvents === "all" || captureEvents === "click") {
props = {
...props,
onClick: stopEvent,
};
}
return (
<span onPointerDown={stopEvent} onClick={stopEvent} className={className}>
<span {...props} className={className}>
{children}
</span>
);
+1 -1
View File
@@ -46,7 +46,7 @@ export const ImageZoom = ({ caption, children }: Props) => {
return (
<React.Suspense fallback={fallback}>
<Styles />
<EventBoundary>
<EventBoundary captureEvents="click">
<Zoom zoomMargin={EditorStyleHelper.padding} ZoomContent={ZoomContent}>
<div>{children}</div>
</Zoom>
+4 -1
View File
@@ -12,6 +12,7 @@ export type Props = {
editorStyle?: React.CSSProperties;
grow?: boolean;
theme: DefaultTheme;
userId?: string;
};
export const fadeIn = keyframes`
@@ -885,7 +886,9 @@ h6 {
}
.${EditorStyleHelper.comment} {
&:not([data-resolved]) {
&:not([data-resolved]):not([data-draft]), &[data-draft][data-user-id="${
props.userId ?? ""
}"] {
border-bottom: 2px solid ${props.theme.commentMarkBackground};
transition: background 100ms ease-in-out;
border-radius: 2px;
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react";
import styled from "styled-components";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
function Pinterest({ matches, ...props }: Props) {
const boardUrl = props.attrs.href;
const frame = React.useRef<HTMLIFrameElement>(null);
const [height, setHeight] = React.useState(400);
React.useEffect(() => {
const handler = (event: MessageEvent<{ type: string; value: number }>) => {
const contentWindow =
frame.current?.contentWindow ||
frame.current?.contentDocument?.defaultView;
if (
event.data.type === "frame-resized" &&
event.source === contentWindow
) {
setHeight(event.data.value);
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
return (
<PinterestFrame
{...props}
ref={frame}
src={`/embeds/pinterest?url=${encodeURIComponent(boardUrl)}`}
title="Pinterest Content"
height={`${height}px`}
width="100%"
/>
);
}
const PinterestFrame = styled(Frame)`
border-radius: 18px;
`;
export default Pinterest;
+13
View File
@@ -14,6 +14,7 @@ import GitLabSnippet from "./GitLabSnippet";
import InVision from "./InVision";
import JSFiddle from "./JSFiddle";
import Linkedin from "./Linkedin";
import Pinterest from "./Pinterest";
import Spotify from "./Spotify";
import Trello from "./Trello";
import Vimeo from "./Vimeo";
@@ -608,6 +609,18 @@ const embeds: EmbedDescriptor[] = [
icon: <Img src="/images/vimeo.png" alt="Vimeo" />,
component: Vimeo,
}),
new EmbedDescriptor({
title: "Pinterest",
keywords: "board moodboard pins",
regexMatch: [
// Match board URLs but exclude pins
/^(?:https?:\/\/)?(?:(?:www\.|[a-z]{2}\.)?pinterest\.(?:com|[a-z]{2,3}))\/(?!pin\/)([^/]+)\/([^/]+)\/?$/,
// Match profile URLs but exclude pins
/^(?:https?:\/\/)?(?:(?:www\.|[a-z]{2}\.)?pinterest\.(?:com|[a-z]{2,3}))\/(?!pin\/)([^/]+)\/?$/,
],
icon: <Img src="/images/pinterest.png" alt="Pinterest" />,
component: Pinterest,
}),
new EmbedDescriptor({
title: "Whimsical",
keywords: "whiteboard",
+18 -5
View File
@@ -22,6 +22,9 @@ export default class Comment extends Mark {
resolved: {
default: false,
},
draft: {
default: false,
},
},
inclusive: false,
parseDOM: [
@@ -38,6 +41,7 @@ export default class Comment extends Mark {
id: dom.getAttribute("id")?.replace("comment-", ""),
userId: dom.getAttribute("data-user-id"),
resolved: !!dom.getAttribute("data-resolved"),
draft: !!dom.getAttribute("data-draft"),
};
},
},
@@ -48,6 +52,7 @@ export default class Comment extends Mark {
class: EditorStyleHelper.comment,
id: `comment-${node.attrs.id}`,
"data-resolved": node.attrs.resolved ? "true" : undefined,
"data-draft": node.attrs.draft ? "true" : undefined,
"data-user-id": node.attrs.userId,
"data-document-id": this.editor?.props.id,
},
@@ -64,9 +69,9 @@ export default class Comment extends Mark {
? {
"Mod-Alt-m": (state, dispatch) => {
if (
isMarkActive(state.schema.marks.comment, { resolved: false })(
state
)
isMarkActive(state.schema.marks.comment, {
resolved: false,
})(state)
) {
return false;
}
@@ -75,6 +80,7 @@ export default class Comment extends Mark {
toggleMark(type, {
id: uuidv4(),
userId: this.options.userId,
draft: true,
}),
collapseSelection()
)(state, dispatch);
@@ -89,7 +95,9 @@ export default class Comment extends Mark {
return this.options.onCreateCommentMark
? (): Command => (state, dispatch) => {
if (
isMarkActive(state.schema.marks.comment, { resolved: false })(state)
isMarkActive(state.schema.marks.comment, {
resolved: false,
})(state)
) {
return false;
}
@@ -98,6 +106,7 @@ export default class Comment extends Mark {
addMark(type, {
id: uuidv4(),
userId: this.options.userId,
draft: true,
}),
collapseSelection()
)(state, dispatch);
@@ -174,7 +183,11 @@ export default class Comment extends Mark {
const commentId = comment.id.replace("comment-", "");
const resolved = comment.getAttribute("data-resolved");
if (commentId && !resolved) {
const draftByUser =
comment.getAttribute("data-draft") &&
comment.getAttribute("data-user-id") === this.options.userId;
if ((commentId && !resolved) || draftByUser) {
this.options?.onClickCommentMark?.(commentId);
}
@@ -94,6 +94,9 @@
"Insights": "Insights",
"Disable viewer insights": "Disable viewer insights",
"Enable viewer insights": "Enable viewer insights",
"Leave document": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"Home": "Home",
"Drafts": "Drafts",
"Trash": "Trash",
@@ -350,6 +353,8 @@
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared",
"Allow anyone with the link to access": "Allow anyone with the link to access",
"Publish to internet": "Publish to internet",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
"{{ count }} people added to the document": "{{ count }} people added to the document",
+62 -20
View File
@@ -2255,6 +2255,18 @@
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.4.1.tgz#5d5e8aee8fce48f5e189bf730ebd1f758f491451"
integrity sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==
"@fast-csv/parse@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@fast-csv/parse/-/parse-5.0.2.tgz#204000dfd661b580a10a8cd035a0e986fc6954a9"
integrity sha512-gMu1Btmm99TP+wc0tZnlH30E/F1Gw1Tah3oMDBHNPe9W8S68ixVHjt89Wg5lh7d9RuQMtwN+sGl5kxR891+fzw==
dependencies:
lodash.escaperegexp "^4.1.2"
lodash.groupby "^4.6.0"
lodash.isfunction "^3.0.9"
lodash.isnil "^4.0.0"
lodash.isundefined "^3.0.1"
lodash.uniq "^4.5.0"
"@formatjs/ecma402-abstract@1.12.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.12.0.tgz#2fb5e8983d5fae2fad9ec6c77aec1803c2b88d8e"
@@ -4544,10 +4556,10 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@^29.5.13":
version "29.5.13"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.13.tgz#8bc571659f401e6a719a7bf0dbcb8b78c71a8adc"
integrity sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==
"@types/jest@^29.5.14":
version "29.5.14"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5"
integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==
dependencies:
expect "^29.0.0"
pretty-format "^29.0.0"
@@ -8109,10 +8121,10 @@ eslint-plugin-prettier@^4.2.1:
dependencies:
prettier-linter-helpers "^1.0.0"
eslint-plugin-react-hooks@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
integrity "sha1-TD5petlbd+k/hkaqoWMMG6YH7dM= sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g=="
eslint-plugin-react-hooks@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596"
integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==
eslint-plugin-react@^7.35.0:
version "7.35.0"
@@ -10955,6 +10967,16 @@ lodash.defaults@^4.2.0:
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
lodash.escaperegexp@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==
lodash.groupby@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1"
integrity sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
@@ -10970,11 +10992,21 @@ lodash.isboolean@^3.0.3:
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isfunction@^3.0.9:
version "3.0.9"
resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
lodash.isnil@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c"
integrity sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
@@ -10990,6 +11022,11 @@ lodash.isstring@^4.0.1:
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
lodash.isundefined@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48"
integrity sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@@ -11010,6 +11047,11 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
lodash@4.17.21, lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
@@ -12585,10 +12627,10 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
dependencies:
prosemirror-model "^1.21.0"
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.34.3:
version "1.34.3"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.34.3.tgz#24b5d2f9196580c23bbe04e9e7a6797cd3a049f6"
integrity sha512-mKZ54PrX19sSaQye+sef+YjBbNu2voNwLS1ivb6aD2IRmxRGW64HU9B644+7OfJStGLyxvOreKqEgfvXa91WIA==
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.36.0:
version "1.36.0"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.36.0.tgz#ab6e444db08b7e3a79c6841c6667df72c7c4f2ec"
integrity sha512-U0GQd5yFvV5qUtT41X1zCQfbw14vkbbKwLlQXhdylEmgpYVHkefXYcC4HHwWOfZa3x6Y8wxDLUBv7dxN5XQ3nA==
dependencies:
prosemirror-model "^1.20.0"
prosemirror-state "^1.0.0"
@@ -14243,10 +14285,10 @@ tempy@^0.6.0:
type-fest "^0.16.0"
unique-string "^2.0.0"
terser@^5.17.4, terser@^5.32.0:
version "5.32.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.32.0.tgz#ee811c0d2d6b741c1cc34a2bc5bcbfc1b5b1f96c"
integrity sha512-v3Gtw3IzpBJ0ugkxEX8U0W6+TnPKRRCWGh1jC/iM/e3Ki5+qvO1L1EAZ56bZasc64aXHwRHNIQEzm6//i5cemQ==
terser@^5.17.4, terser@^5.36.0:
version "5.36.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.36.0.tgz#8b0dbed459ac40ff7b4c9fd5a3a2029de105180e"
integrity sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==
dependencies:
"@jridgewell/source-map" "^0.3.3"
acorn "^8.8.2"
@@ -15566,7 +15608,7 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity "sha1-ApTrPe4FAo0x7hpfosVWpqrxChs= sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
zod@^3.19.1, zod@^3.22.4:
version "3.22.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
integrity "sha1-8xw6k4b2Gx8iivVvqpJV6EXPP/8= sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="
zod@^3.19.1, zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==