feat: Nested document sharing (#2075)

* migration

* frontend routing, api permissioning

* feat: apiVersion=2

* feat: re-writing document links to point to share

* poc nested documents on share links

* fix: nested shareId permissions

* ui and language tweaks, comments

* breadcrumbs

* Add icons to reference list items

* refactor: Breadcrumb component

* tweaks

* Add shared parent note
This commit is contained in:
Tom Moor
2021-05-22 19:34:05 -07:00
committed by GitHub
parent dc4b5588b7
commit 44920a25f4
31 changed files with 1134 additions and 282 deletions

View File

@ -1,193 +1,87 @@
// @flow // @flow
import { observer } from "mobx-react"; import { GoToIcon } from "outline-icons";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex"; import Flex from "components/Flex";
import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu"; import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers";
type Props = {| type MenuItem = {|
document: Document, icon?: React.Node,
children?: React.Node, title: React.Node,
onlyText: boolean, to?: string,
|}; |};
function Icon({ document }) { type Props = {|
const { t } = useTranslation(); items: MenuItem[],
max?: number,
children?: React.Node,
highlightFirstItem?: boolean,
|};
if (document.isDeleted) { function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
return ( const totalItems = items.length;
<> let topLevelItems: MenuItem[] = [...items];
<CategoryName to="/trash"> let overflowItems;
<TrashIcon color="currentColor" />
&nbsp;
<span>{t("Trash")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CategoryName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>{t("Archive")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CategoryName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>{t("Drafts")}</span>
</CategoryName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CategoryName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>{t("Templates")}</span>
</CategoryName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = ({ document, children, onlyText }: Props) => { // chop middle breadcrumbs and present a "..." menu instead
const { collections } = useStores(); if (totalItems > max) {
const { t } = useTranslation(); const halfMax = Math.floor(max / 2);
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
if (!collections.isLoaded) { topLevelItems.splice(halfMax, 0, {
return; title: <BreadcrumbMenu items={overflowItems} />,
});
} }
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [];
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return ( return (
<Flex justify="flex-start" align="center"> <Flex justify="flex-start" align="center">
<Icon document={document} /> {topLevelItems.map((item, index) => (
<CollectionName to={collectionUrl(collection.id)}> <React.Fragment key={item.to || index}>
<CollectionIcon collection={collection} expanded /> {item.icon}
&nbsp; {item.to ? (
<span>{collection.name}</span> <Item
</CollectionName> to={item.to}
{isNestedDocument && ( $withIcon={!!item.icon}
<> $highlight={highlightFirstItem && index === 0}
<Slash /> <BreadcrumbMenu path={menuPath} /> >
</> {item.title}
)} </Item>
{lastPath && ( ) : (
<> item.title
<Slash />{" "} )}
<Crumb to={lastPath.url} title={lastPath.title}> {index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
{lastPath.title} </React.Fragment>
</Crumb> ))}
</>
)}
{children} {children}
</Flex> </Flex>
); );
}; }
export const Slash = styled(GoToIcon)` const Slash = styled(GoToIcon)`
flex-shrink: 0; flex-shrink: 0;
fill: ${(props) => props.theme.divider}; fill: ${(props) => props.theme.divider};
`; `;
const SmallSlash = styled(GoToIcon)` const Item = styled(Link)`
width: 12px; display: flex;
height: 12px; flex-shrink: 1;
vertical-align: middle; min-width: 0;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
const Crumb = styled(Link)`
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};
font-size: 15px; font-size: 15px;
height: 24px; height: 24px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
`; `;
const CollectionName = styled(Link)` export default Breadcrumb;
display: flex;
flex-shrink: 1;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`;
const CategoryName = styled(CollectionName)`
flex-shrink: 0;
`;
export default observer(Breadcrumb);

View File

@ -0,0 +1,137 @@
// @flow
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import CollectionIcon from "components/CollectionIcon";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
children?: React.Node,
onlyText: boolean,
|};
function useCategory(document) {
const { t } = useTranslation();
if (document.isDeleted) {
return {
icon: <TrashIcon color="currentColor" />,
title: t("Trash"),
to: "/trash",
};
}
if (document.isArchived) {
return {
icon: <ArchiveIcon color="currentColor" />,
title: t("Archive"),
to: "/archive",
};
}
if (document.isDraft) {
return {
icon: <EditIcon color="currentColor" />,
title: t("Drafts"),
to: "/drafts",
};
}
if (document.isTemplate) {
return {
icon: <ShapesIcon color="currentColor" />,
title: t("Templates"),
to: "/templates",
};
}
return null;
}
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
let collection = collections.get(document.collectionId);
if (!collection) {
collection = {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
};
}
const path = React.useMemo(
() =>
collection && collection.pathToDocument
? collection.pathToDocument(document.id).slice(0, -1)
: [],
[collection, document.id]
);
const items = React.useMemo(() => {
let output = [];
if (category) {
output.push(category);
}
if (collection) {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.id),
});
}
path.forEach((p) => {
output.push({
title: p.title,
to: p.url,
});
});
return output;
}, [path, category, collection]);
if (!collections.isLoaded) {
return;
}
if (onlyText === true) {
return (
<>
{collection.name}
{path.map((n) => (
<React.Fragment key={n.id}>
<SmallSlash />
{n.title}
</React.Fragment>
))}
</>
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
};
const SmallSlash = styled(GoToIcon)`
width: 12px;
height: 12px;
vertical-align: middle;
flex-shrink: 0;
fill: ${(props) => props.theme.slate};
opacity: 0.5;
`;
export default observer(DocumentBreadcrumb);

View File

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import Document from "models/Document"; import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb"; import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Time from "components/Time"; import Time from "components/Time";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
@ -142,7 +142,7 @@ function DocumentMeta({
<span> <span>
&nbsp;{t("in")}&nbsp; &nbsp;{t("in")}&nbsp;
<strong> <strong>
<Breadcrumb document={document} onlyText /> <DocumentBreadcrumb document={document} onlyText />
</strong> </strong>
</span> </span>
)} )}

View File

@ -27,6 +27,7 @@ export type Props = {|
grow?: boolean, grow?: boolean,
disableEmbeds?: boolean, disableEmbeds?: boolean,
ui?: UiStore, ui?: UiStore,
shareId?: ?string,
autoFocus?: boolean, autoFocus?: boolean,
template?: boolean, template?: boolean,
placeholder?: string, placeholder?: string,
@ -55,7 +56,7 @@ type PropsWithRef = Props & {
}; };
function Editor(props: PropsWithRef) { function Editor(props: PropsWithRef) {
const { id, ui, history } = props; const { id, ui, shareId, history } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const isPrinting = useMediaQuery("print"); const isPrinting = useMediaQuery("print");
@ -89,12 +90,16 @@ function Editor(props: PropsWithRef) {
} }
} }
if (shareId) {
navigateTo = `/share/${shareId}${navigateTo}`;
}
history.push(navigateTo); history.push(navigateTo);
} else if (href) { } else if (href) {
window.open(href, "_blank"); window.open(href, "_blank");
} }
}, },
[history] [history, shareId]
); );
const onShowToast = React.useCallback( const onShowToast = React.useCallback(

View File

@ -144,9 +144,10 @@ class SocketProvider extends React.Component<Props> {
// otherwise, grab the latest version of the document // otherwise, grab the latest version of the document
try { try {
document = await documents.fetch(documentId, { const response = await documents.fetch(documentId, {
force: true, force: true,
}); });
document = response.document;
} catch (err) { } catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) { if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId); documents.remove(documentId);

View File

@ -36,7 +36,7 @@ export default function useImportDocument(
const redirect = files.length === 1; const redirect = files.length === 1;
if (documentId && !collectionId) { if (documentId && !collectionId) {
const document = await documents.fetch(documentId); const { document } = await documents.fetch(documentId);
invariant(document, "Document not available"); invariant(document, "Document not available");
cId = document.collectionId; cId = document.collectionId;
} }

View File

@ -6,11 +6,17 @@ import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton"; import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template"; import Template from "components/ContextMenu/Template";
type MenuItem = {|
icon?: React.Node,
title: React.Node,
to?: string,
|};
type Props = { type Props = {
path: Array<any>, items: MenuItem[],
}; };
export default function BreadcrumbMenu({ path }: Props) { export default function BreadcrumbMenu({ items }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const menu = useMenuState({ const menu = useMenuState({
modal: true, modal: true,
@ -21,13 +27,7 @@ export default function BreadcrumbMenu({ path }: Props) {
<> <>
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} /> <OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}> <ContextMenu {...menu} aria-label={t("Path to document")}>
<Template <Template {...menu} items={items} />
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</ContextMenu> </ContextMenu>
</> </>
); );

View File

@ -10,6 +10,7 @@ class Share extends BaseModel {
documentTitle: string; documentTitle: string;
documentUrl: string; documentUrl: string;
lastAccessedAt: ?string; lastAccessedAt: ?string;
includeChildDocuments: boolean;
createdBy: User; createdBy: User;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@ -4,6 +4,7 @@ import { Switch } from "react-router-dom";
import DelayedMount from "components/DelayedMount"; import DelayedMount from "components/DelayedMount";
import FullscreenLoading from "components/FullscreenLoading"; import FullscreenLoading from "components/FullscreenLoading";
import Route from "components/ProfiledRoute"; import Route from "components/ProfiledRoute";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
const Authenticated = React.lazy(() => import("components/Authenticated")); const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated")); const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
@ -25,6 +26,11 @@ export default function Routes() {
<Route exact path="/create" component={Login} /> <Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} /> <Route exact path="/logout" component={Logout} />
<Route exact path="/share/:shareId" component={KeyedDocument} /> <Route exact path="/share/:shareId" component={KeyedDocument} />
<Route
exact
path={`/share/:shareId/doc/${slug}`}
component={KeyedDocument}
/>
<Authenticated> <Authenticated>
<AuthenticatedRoutes /> <AuthenticatedRoutes />
</Authenticated> </Authenticated>

View File

@ -22,11 +22,10 @@ import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar"; import HideSidebar from "./HideSidebar";
import Loading from "./Loading"; import Loading from "./Loading";
import SocketPresence from "./SocketPresence"; import SocketPresence from "./SocketPresence";
import { type LocationWithState } from "types"; import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors"; import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers"; import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls"; import { isInternalUrl } from "utils/urls";
type Props = {| type Props = {|
match: Match, match: Match,
location: LocationWithState, location: LocationWithState,
@ -41,6 +40,7 @@ type Props = {|
@observer @observer
class DataLoader extends React.Component<Props> { class DataLoader extends React.Component<Props> {
@observable sharedTree: ?NavigationNode;
@observable document: ?Document; @observable document: ?Document;
@observable revision: ?Revision; @observable revision: ?Revision;
@observable error: ?Error; @observable error: ?Error;
@ -89,7 +89,7 @@ class DataLoader extends React.Component<Props> {
// search for exact internal document // search for exact internal document
const slug = parseDocumentSlug(term); const slug = parseDocumentSlug(term);
try { try {
const document = await this.props.documents.fetch(slug); const { document } = await this.props.documents.fetch(slug);
const time = distanceInWordsToNow(document.updatedAt, { const time = distanceInWordsToNow(document.updatedAt, {
addSuffix: true, addSuffix: true,
}); });
@ -159,10 +159,13 @@ class DataLoader extends React.Component<Props> {
} }
try { try {
this.document = await this.props.documents.fetch(documentSlug, { const response = await this.props.documents.fetch(documentSlug, {
shareId, shareId,
}); });
this.document = response.document;
this.sharedTree = response.sharedTree;
if (revisionId && revisionId !== "latest") { if (revisionId && revisionId !== "latest") {
await this.loadRevision(); await this.loadRevision();
} else { } else {
@ -249,6 +252,7 @@ class DataLoader extends React.Component<Props> {
readOnly={!this.isEditing || !abilities.update || document.isArchived} readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink} onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink} onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/> />
</SocketPresence> </SocketPresence>
); );

View File

@ -29,8 +29,9 @@ import Editor from "./Editor";
import Header from "./Header"; import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton"; import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import MarkAsViewed from "./MarkAsViewed"; import MarkAsViewed from "./MarkAsViewed";
import PublicReferences from "./PublicReferences";
import References from "./References"; import References from "./References";
import { type LocationWithState, type Theme } from "types"; import { type LocationWithState, type NavigationNode, type Theme } from "types";
import { isCustomDomain } from "utils/domains"; import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji"; import { emojiToUrl } from "utils/emoji";
import { meta } from "utils/keyboard"; import { meta } from "utils/keyboard";
@ -57,6 +58,7 @@ type Props = {
match: Match, match: Match,
history: RouterHistory, history: RouterHistory,
location: LocationWithState, location: LocationWithState,
sharedTree: ?NavigationNode,
abilities: Object, abilities: Object,
document: Document, document: Document,
revision: Revision, revision: Revision,
@ -311,7 +313,8 @@ class DocumentScene extends React.Component<Props> {
match, match,
} = this.props; } = this.props;
const team = auth.team; const team = auth.team;
const isShare = !!match.params.shareId; const { shareId } = match.params;
const isShare = !!shareId;
const value = revision ? revision.text : document.text; const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate; const injectTemplate = document.injectTemplate;
@ -367,7 +370,7 @@ class DocumentScene extends React.Component<Props> {
)} )}
<Header <Header
document={document} document={document}
isShare={isShare} shareId={shareId}
isRevision={!!revision} isRevision={!!revision}
isDraft={document.isDraft} isDraft={document.isDraft}
isEditing={!readOnly} isEditing={!readOnly}
@ -377,6 +380,7 @@ class DocumentScene extends React.Component<Props> {
document.isSaving || this.isPublishing || this.isEmpty document.isSaving || this.isPublishing || this.isEmpty
} }
savingIsDisabled={document.isSaving || this.isEmpty} savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
goBack={this.goBack} goBack={this.goBack}
onSave={this.onSave} onSave={this.onSave}
/> />
@ -420,7 +424,7 @@ class DocumentScene extends React.Component<Props> {
<Editor <Editor
id={document.id} id={document.id}
innerRef={this.editor} innerRef={this.editor}
isShare={isShare} shareId={shareId}
isDraft={document.isDraft} isDraft={document.isDraft}
template={document.isTemplate} template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")} key={[injectTemplate, disableEmbeds].join("-")}
@ -442,6 +446,15 @@ class DocumentScene extends React.Component<Props> {
readOnlyWriteCheckboxes={readOnly && abilities.update} readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui} ui={this.props.ui}
> >
{shareId && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<PublicReferences
shareId={shareId}
documentId={document.id}
sharedTree={this.props.sharedTree}
/>
</ReferencesWrapper>
)}
{!isShare && !revision && ( {!isShare && !revision && (
<> <>
<MarkAsViewed document={document} /> <MarkAsViewed document={document} />

View File

@ -24,7 +24,7 @@ type Props = {|
title: string, title: string,
document: Document, document: Document,
isDraft: boolean, isDraft: boolean,
isShare: boolean, shareId: ?string,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any, onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any }, innerRef: { current: any },
children: React.Node, children: React.Node,
@ -97,7 +97,7 @@ class DocumentEditor extends React.Component<Props> {
title, title,
onChangeTitle, onChangeTitle,
isDraft, isDraft,
isShare, shareId,
readOnly, readOnly,
innerRef, innerRef,
children, children,
@ -118,7 +118,7 @@ class DocumentEditor extends React.Component<Props> {
$isStarred={document.isStarred} $isStarred={document.isStarred}
> >
<span>{normalizedTitle}</span>{" "} <span>{normalizedTitle}</span>{" "}
{!isShare && <StarButton document={document} size={32} />} {!shareId && <StarButton document={document} size={32} />}
</Title> </Title>
) : ( ) : (
<Title <Title
@ -144,11 +144,12 @@ class DocumentEditor extends React.Component<Props> {
onHoverLink={this.handleLinkActive} onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash} scrollTo={window.location.hash}
readOnly={readOnly} readOnly={readOnly}
shareId={shareId}
grow grow
{...rest} {...rest}
/> />
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />} {!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent && !isShare && readOnly && ( {this.activeLinkEvent && !shareId && readOnly && (
<HoverPreview <HoverPreview
node={this.activeLinkEvent.target} node={this.activeLinkEvent.target}
event={this.activeLinkEvent} event={this.activeLinkEvent}

View File

@ -13,23 +13,26 @@ import styled from "styled-components";
import Document from "models/Document"; import Document from "models/Document";
import { Action, Separator } from "components/Actions"; import { Action, Separator } from "components/Actions";
import Badge from "components/Badge"; import Badge from "components/Badge";
import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button"; import Button from "components/Button";
import Collaborators from "components/Collaborators"; import Collaborators from "components/Collaborators";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Header from "components/Header"; import Header from "components/Header";
import Tooltip from "components/Tooltip"; import Tooltip from "components/Tooltip";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton"; import ShareButton from "./ShareButton";
import useMobile from "hooks/useMobile"; import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu"; import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TemplatesMenu from "menus/TemplatesMenu"; import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard"; import { metaDisplay } from "utils/keyboard";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
type Props = {| type Props = {|
document: Document, document: Document,
isShare: boolean, sharedTree: ?NavigationNode,
shareId: ?string,
isDraft: boolean, isDraft: boolean,
isEditing: boolean, isEditing: boolean,
isRevision: boolean, isRevision: boolean,
@ -47,7 +50,7 @@ type Props = {|
function DocumentHeader({ function DocumentHeader({
document, document,
isShare, shareId,
isEditing, isEditing,
isDraft, isDraft,
isPublishing, isPublishing,
@ -55,6 +58,7 @@ function DocumentHeader({
isSaving, isSaving,
savingIsDisabled, savingIsDisabled,
publishingIsDisabled, publishingIsDisabled,
sharedTree,
onSave, onSave,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -115,11 +119,19 @@ function DocumentHeader({
</Action> </Action>
); );
if (isShare) { if (shareId) {
return ( return (
<Header <Header
title={document.title} title={document.title}
breadcrumb={toc} breadcrumb={
<PublicBreadcrumb
documentId={document.id}
shareId={shareId}
sharedTree={sharedTree}
>
{toc}
</PublicBreadcrumb>
}
actions={canEdit ? editAction : <div />} actions={canEdit ? editAction : <div />}
/> />
); );
@ -129,14 +141,9 @@ function DocumentHeader({
<> <>
<Header <Header
breadcrumb={ breadcrumb={
<Breadcrumb document={document}> <DocumentBreadcrumb document={document}>
{!isEditing && ( {!isEditing && toc}
<> </DocumentBreadcrumb>
<Slash />
{toc}
</>
)}
</Breadcrumb>
} }
title={ title={
<> <>

View File

@ -0,0 +1,54 @@
// @flow
import * as React from "react";
import Breadcrumb from "components/Breadcrumb";
import type { NavigationNode } from "types";
type Props = {|
documentId: string,
shareId: string,
sharedTree: ?NavigationNode,
children?: React.Node,
|};
function pathToDocument(sharedTree, documentId) {
let path = [];
const traveler = (nodes, previousPath) => {
nodes.forEach((childNode) => {
const newPath = [...previousPath, childNode];
if (childNode.id === documentId) {
path = newPath;
return;
}
return traveler(childNode.children, newPath);
});
};
if (sharedTree) {
traveler([sharedTree], []);
}
return path;
}
const PublicBreadcrumb = ({
documentId,
shareId,
sharedTree,
children,
}: Props) => {
const items = React.useMemo(
() =>
pathToDocument(sharedTree, documentId)
.slice(0, -1)
.map((item) => {
return {
...item,
to: `/share/${shareId}${item.url}`,
};
}),
[sharedTree, shareId, documentId]
);
return <Breadcrumb items={items} children={children} />;
};
export default PublicBreadcrumb;

View File

@ -0,0 +1,57 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Subheading from "components/Subheading";
import ReferenceListItem from "./ReferenceListItem";
import { type NavigationNode } from "types";
type Props = {|
shareId: string,
documentId: string,
sharedTree: NavigationNode,
|};
function PublicReferences(props: Props) {
const { t } = useTranslation();
const { shareId, documentId, sharedTree } = props;
// The sharedTree is the entire document tree starting at the shared document
// we must filter down the tree to only the part with the document we're
// currently viewing
const children = React.useMemo(() => {
let result;
function findChildren(node) {
if (!node) return;
if (node.id === documentId) {
result = node.children;
} else {
node.children.forEach((node) => {
if (result) {
return;
}
findChildren(node);
});
}
return result;
}
return findChildren(sharedTree) || [];
}, [documentId, sharedTree]);
if (!children.length) {
return null;
}
return (
<>
<Subheading>{t("Nested documents")}</Subheading>
{children.map((node) => (
<ReferenceListItem key={node.id} document={node} shareId={shareId} />
))}
</>
);
}
export default observer(PublicReferences);

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
@ -8,6 +9,7 @@ import DocumentMeta from "components/DocumentMeta";
import type { NavigationNode } from "types"; import type { NavigationNode } from "types";
type Props = {| type Props = {|
shareId?: string,
document: Document | NavigationNode, document: Document | NavigationNode,
anchor?: string, anchor?: string,
showCollection?: boolean, showCollection?: boolean,
@ -31,6 +33,8 @@ const DocumentLink = styled(Link)`
`; `;
const Title = styled.h3` const Title = styled.h3`
display: flex;
align-items: center;
max-width: 90%; max-width: 90%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -43,27 +47,52 @@ const Title = styled.h3`
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`; `;
@observer const StyledDocumentIcon = styled(DocumentIcon)`
class ReferenceListItem extends React.Component<Props> { margin-left: -4px;
render() { color: ${(props) => props.theme.textSecondary};
const { document, showCollection, anchor, ...rest } = this.props; `;
return ( const Emoji = styled.span`
<DocumentLink display: inline-flex;
to={{ align-items: center;
pathname: document.url, justify-content: center;
hash: anchor ? `d-${anchor}` : undefined, margin-left: -4px;
state: { title: document.title }, font-size: 16px;
}} width: 24px;
{...rest} height: 24px;
> `;
<Title>{document.title}</Title>
{document.updatedBy && ( function ReferenceListItem({
<DocumentMeta document={document} showCollection={showCollection} /> document,
)} showCollection,
</DocumentLink> anchor,
); shareId,
} ...rest
}: Props) {
return (
<DocumentLink
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: { title: document.title },
}}
{...rest}
>
<Title>
{document.emoji ? (
<Emoji>{document.emoji}</Emoji>
) : (
<StyledDocumentIcon color="currentColor" />
)}{" "}
{document.emoji
? document.title.replace(new RegExp(`^${document.emoji}`), "")
: document.title}
</Title>
{document.updatedBy && (
<DocumentMeta document={document} showCollection={showCollection} />
)}
</DocumentLink>
);
} }
export default ReferenceListItem; export default observer(ReferenceListItem);

View File

@ -19,7 +19,9 @@ function ShareButton({ document }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { shares } = useStores(); const { shares } = useStores();
const share = shares.getByDocumentId(document.id); const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published; const sharedParent = shares.getByDocumentParents(document.id);
const isPubliclyShared =
(share && share.published) || (sharedParent && sharedParent.published);
const popover = usePopoverState({ const popover = usePopoverState({
gutter: 0, gutter: 0,
placement: "bottom-end", placement: "bottom-end",
@ -57,6 +59,7 @@ function ShareButton({ document }: Props) {
<SharePopover <SharePopover
document={document} document={document}
share={share} share={share}
sharedParent={sharedParent}
onSubmit={popover.hide} onSubmit={popover.hide}
/> />
</Popover> </Popover>

View File

@ -4,7 +4,7 @@ import invariant from "invariant";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { GlobeIcon, PadlockIcon } from "outline-icons"; import { GlobeIcon, PadlockIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import Document from "models/Document"; import Document from "models/Document";
import Share from "models/Share"; import Share from "models/Share";
@ -13,23 +13,25 @@ import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex"; import Flex from "components/Flex";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import Input from "components/Input"; import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch"; import Switch from "components/Switch";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
type Props = {| type Props = {|
document: Document, document: Document,
share: Share, share: Share,
sharedParent: ?Share,
onSubmit: () => void, onSubmit: () => void,
|}; |};
function DocumentShare({ document, share, onSubmit }: Props) { function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { policies, shares, ui } = useStores(); const { policies, shares, ui } = useStores();
const [isCopied, setIsCopied] = React.useState(false); const [isCopied, setIsCopied] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const timeout = React.useRef<?TimeoutID>(); const timeout = React.useRef<?TimeoutID>();
const can = policies.abilities(share ? share.id : ""); const can = policies.abilities(share ? share.id : "");
const canPublish = can.update && !document.isTemplate; const canPublish = can.update && !document.isTemplate;
const isPubliclyShared = (share && share.published) || sharedParent;
React.useEffect(() => { React.useEffect(() => {
document.share(); document.share();
@ -41,14 +43,26 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const share = shares.getByDocumentId(document.id); const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist"); invariant(share, "Share must exist");
setIsSaving(true);
try { try {
await share.save({ published: event.currentTarget.checked }); await share.save({ published: event.currentTarget.checked });
} catch (err) { } catch (err) {
ui.showToast(err.message, { type: "error" }); ui.showToast(err.message, { type: "error" });
} finally { }
setIsSaving(false); },
[document.id, shares, ui]
);
const handleChildDocumentsChange = React.useCallback(
async (event) => {
const share = shares.getByDocumentId(document.id);
invariant(share, "Share must exist");
try {
await share.save({
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
ui.showToast(err.message, { type: "error" });
} }
}, },
[document.id, shares, ui] [document.id, shares, ui]
@ -68,7 +82,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
return ( return (
<> <>
<Heading> <Heading>
{share && share.published ? ( {isPubliclyShared ? (
<GlobeIcon size={28} color="currentColor" /> <GlobeIcon size={28} color="currentColor" />
) : ( ) : (
<PadlockIcon size={28} color="currentColor" /> <PadlockIcon size={28} color="currentColor" />
@ -76,20 +90,30 @@ function DocumentShare({ document, share, onSubmit }: Props) {
{t("Share this document")} {t("Share this document")}
</Heading> </Heading>
{sharedParent && (
<Notice>
<Trans
defaults="This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared"
values={{ documentTitle: sharedParent.documentTitle }}
components={{ em: <strong /> }}
/>
</Notice>
)}
{canPublish && ( {canPublish && (
<PrivacySwitch> <SwitchWrapper>
<Switch <Switch
id="published" id="published"
label={t("Publish to internet")} label={t("Publish to internet")}
onChange={handlePublishedChange} onChange={handlePublishedChange}
checked={share ? share.published : false} checked={share ? share.published : false}
disabled={!share || isSaving} disabled={!share}
/> />
<Privacy> <SwitchLabel>
<PrivacyText> <SwitchText>
{share.published {share.published
? t("Anyone with the link can view this document") ? t("Anyone with the link can view this document")
: t("Only team members with access can view")} : t("Only team members with permission can view")}
{share.lastAccessedAt && ( {share.lastAccessedAt && (
<> <>
.{" "} .{" "}
@ -100,9 +124,27 @@ function DocumentShare({ document, share, onSubmit }: Props) {
})} })}
</> </>
)} )}
</PrivacyText> </SwitchText>
</Privacy> </SwitchLabel>
</PrivacySwitch> </SwitchWrapper>
)}
{share && share.published && (
<SwitchWrapper>
<Switch
id="includeChildDocuments"
label={t("Share nested documents")}
onChange={handleChildDocumentsChange}
checked={share ? share.includeChildDocuments : false}
disabled={!share}
/>
<SwitchLabel>
<SwitchText>
{share.includeChildDocuments
? t("Nested documents are publicly available")
: t("Nested documents are not shared")}
</SwitchText>
</SwitchLabel>
</SwitchWrapper>
)} )}
<Flex> <Flex>
<InputLink <InputLink
@ -130,7 +172,7 @@ const Heading = styled.h2`
margin-left: -4px; margin-left: -4px;
`; `;
const PrivacySwitch = styled.div` const SwitchWrapper = styled.div`
margin: 20px 0; margin: 20px 0;
`; `;
@ -139,7 +181,7 @@ const InputLink = styled(Input)`
margin-right: 8px; margin-right: 8px;
`; `;
const Privacy = styled(Flex)` const SwitchLabel = styled(Flex)`
flex-align: center; flex-align: center;
svg { svg {
@ -147,9 +189,9 @@ const Privacy = styled(Flex)`
} }
`; `;
const PrivacyText = styled(HelpText)` const SwitchText = styled(HelpText)`
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
`; `;
export default observer(DocumentShare); export default observer(SharePopover);

View File

@ -10,7 +10,12 @@ import BaseStore from "stores/BaseStore";
import RootStore from "stores/RootStore"; import RootStore from "stores/RootStore";
import Document from "models/Document"; import Document from "models/Document";
import env from "env"; import env from "env";
import type { FetchOptions, PaginationParams, SearchResult } from "types"; import type {
NavigationNode,
FetchOptions,
PaginationParams,
SearchResult,
} from "types";
import { client } from "utils/ApiClient"; import { client } from "utils/ApiClient";
type ImportOptions = { type ImportOptions = {
@ -447,30 +452,30 @@ export default class DocumentsStore extends BaseStore<Document> {
fetch = async ( fetch = async (
id: string, id: string,
options: FetchOptions = {} options: FetchOptions = {}
): Promise<?Document> => { ): Promise<{ document: ?Document, sharedTree?: NavigationNode }> => {
if (!options.prefetch) this.isFetching = true; if (!options.prefetch) this.isFetching = true;
try { try {
const doc: ?Document = this.data.get(id) || this.getByUrl(id); const doc: ?Document = this.data.get(id) || this.getByUrl(id);
const policy = doc ? this.rootStore.policies.get(doc.id) : undefined; const policy = doc ? this.rootStore.policies.get(doc.id) : undefined;
if (doc && policy && !options.force) { if (doc && policy && !options.force) {
return doc; return { document: doc };
} }
const res = await client.post("/documents.info", { const res = await client.post("/documents.info", {
id, id,
shareId: options.shareId, shareId: options.shareId,
apiVersion: 2,
}); });
invariant(res && res.data, "Document not available"); invariant(res && res.data, "Document not available");
this.addPolicies(res.policies); this.addPolicies(res.policies);
this.add(res.data); this.add(res.data.document);
runInAction("DocumentsStore#fetch", () => { return {
this.isLoaded = true; document: this.data.get(res.data.document.id),
}); sharedTree: res.data.sharedTree,
};
return this.data.get(res.data.id);
} finally { } finally {
this.isFetching = false; this.isFetching = false;
} }

View File

@ -46,19 +46,42 @@ export default class SharesStore extends BaseStore<Share> {
this.isFetching = true; this.isFetching = true;
try { try {
const res = await client.post(`/${this.modelName}s.info`, { documentId }); const res = await client.post(`/${this.modelName}s.info`, {
documentId,
apiVersion: 2,
});
if (isUndefined(res)) return; if (isUndefined(res)) return;
invariant(res && res.data, "Data should be available"); invariant(res && res.data, "Data should be available");
this.addPolicies(res.policies); this.addPolicies(res.policies);
return this.add(res.data); return res.data.shares.map(this.add);
} finally { } finally {
this.isFetching = false; this.isFetching = false;
} }
} }
getByDocumentId = (documentId): ?Share => { getByDocumentParents = (documentId: string): ?Share => {
const document = this.rootStore.documents.get(documentId);
if (!document) return;
const collection = this.rootStore.collections.get(document.collectionId);
if (!collection) return;
const parentIds = collection
.pathToDocument(documentId)
.slice(0, -1)
.map((p) => p.id);
for (const parentId of parentIds) {
const share = this.getByDocumentId(parentId);
if (share && share.includeChildDocuments && share.published) {
return share;
}
}
};
getByDocumentId = (documentId: string): ?Share => {
return find(this.orderedData, (share) => share.documentId === documentId); return find(this.orderedData, (share) => share.documentId === documentId);
}; };
} }

View File

@ -471,11 +471,17 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => {
}; };
}); });
async function loadDocument({ id, shareId, user }) { async function loadDocument({
id,
shareId,
user,
}): Promise<{ document: Document, share?: Share, collection: Collection }> {
let document; let document;
let collection;
let share;
if (shareId) { if (shareId) {
const share = await Share.findOne({ share = await Share.findOne({
where: { where: {
revokedAt: { [Op.eq]: null }, revokedAt: { [Op.eq]: null },
id: shareId, id: shareId,
@ -497,7 +503,20 @@ async function loadDocument({ id, shareId, user }) {
throw new InvalidRequestError("Document could not be found for shareId"); throw new InvalidRequestError("Document could not be found for shareId");
} }
if (user) { // It is possible to pass both an id and a shareId to the documents.info
// endpoint. In this case we'll load the document based on the `id` and check
// if the provided share token allows access. This is used by the frontend
// to navigate nested documents from a single share link.
if (id) {
document = await Document.findByPk(id, {
userId: user ? user.id : undefined,
paranoid: false,
});
// otherwise, if the user has an authenticated session make sure to load
// with their details so that we can return the correct policies, they may
// be able to edit the shared document
} else if (user) {
document = await Document.findByPk(share.documentId, { document = await Document.findByPk(share.documentId, {
userId: user.id, userId: user.id,
paranoid: false, paranoid: false,
@ -506,15 +525,31 @@ async function loadDocument({ id, shareId, user }) {
document = share.document; document = share.document;
} }
// "published" === on the public internet. So if the share isn't published
// then we must have permission to read the document
if (!share.published) { if (!share.published) {
authorize(user, "read", document); authorize(user, "read", document);
} }
const collection = await Collection.findByPk(document.collectionId); // It is possible to disable sharing at the collection so we must check
collection = await Collection.findByPk(document.collectionId);
if (!collection.sharing) { if (!collection.sharing) {
throw new AuthorizationError(); throw new AuthorizationError();
} }
// If we're attempting to load a document that isn't the document originally
// shared then includeChildDocuments must be enabled and the document must
// still be nested within the shared document
if (share.document.id !== document.id) {
if (
!share.includeChildDocuments ||
!collection.isChildDocument(share.document.id, document.id)
) {
throw new AuthorizationError();
}
}
// It is possible to disable sharing at the team level so we must check
const team = await Team.findByPk(document.teamId); const team = await Team.findByPk(document.teamId);
if (!team.sharing) { if (!team.sharing) {
throw new AuthorizationError(); throw new AuthorizationError();
@ -535,21 +570,41 @@ async function loadDocument({ id, shareId, user }) {
} else { } else {
authorize(user, "read", document); authorize(user, "read", document);
} }
collection = document.collection;
} }
return document; return { document, share, collection };
} }
router.post("documents.info", auth({ required: false }), async (ctx) => { router.post("documents.info", auth({ required: false }), async (ctx) => {
const { id, shareId } = ctx.body; const { id, shareId, apiVersion } = ctx.body;
ctx.assertPresent(id || shareId, "id or shareId is required"); ctx.assertPresent(id || shareId, "id or shareId is required");
const user = ctx.state.user; const { user } = ctx.state;
const document = await loadDocument({ id, shareId, user }); const { document, share, collection } = await loadDocument({
id,
shareId,
user,
});
const isPublic = cannot(user, "read", document); const isPublic = cannot(user, "read", document);
const serializedDocument = await presentDocument(document, { isPublic });
// Passing apiVersion=2 has a single effect, to change the response payload to
// include document and sharedTree keys.
const data =
apiVersion === 2
? {
document: serializedDocument,
sharedTree:
share && share.includeChildDocuments
? collection.getDocumentTree(share.documentId)
: undefined,
}
: serializedDocument;
ctx.body = { ctx.body = {
data: await presentDocument(document, { isPublic }), data,
policies: isPublic ? undefined : presentPolicies(user, [document]), policies: isPublic ? undefined : presentPolicies(user, [document]),
}; };
}); });
@ -559,7 +614,7 @@ router.post("documents.export", auth({ required: false }), async (ctx) => {
ctx.assertPresent(id || shareId, "id or shareId is required"); ctx.assertPresent(id || shareId, "id or shareId is required");
const user = ctx.state.user; const user = ctx.state.user;
const document = await loadDocument({ id, shareId, user }); const { document } = await loadDocument({ id, shareId, user });
ctx.body = { ctx.body = {
data: document.toMarkdown(), data: document.toMarkdown(),

View File

@ -98,6 +98,109 @@ describe("#documents.info", () => {
expect(share.lastAccessedAt).toBeTruthy(); expect(share.lastAccessedAt).toBeTruthy();
}); });
describe("apiVersion=2", () => {
it("should return sharedTree from shareId", async () => {
const { document, collection, user } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/documents.info", {
body: { shareId: share.id, id: childDocument.id, apiVersion: 2 },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.document.id).toEqual(childDocument.id);
expect(body.data.document.createdBy).toEqual(undefined);
expect(body.data.document.updatedBy).toEqual(undefined);
expect(body.data.sharedTree).toEqual(collection.documentStructure[0]);
await share.reload();
expect(share.lastAccessedAt).toBeTruthy();
});
it("should return sharedTree from shareId with id of nested document", async () => {
const { document, user } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: true,
});
const res = await server.post("/api/documents.info", {
body: { shareId: share.id, apiVersion: 2 },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.document.id).toEqual(document.id);
expect(body.data.document.createdBy).toEqual(undefined);
expect(body.data.document.updatedBy).toEqual(undefined);
expect(body.data.sharedTree).toEqual(document.toJSON());
await share.reload();
expect(share.lastAccessedAt).toBeTruthy();
});
it("should not return sharedTree if child documents not shared", async () => {
const { document, user } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: false,
});
const res = await server.post("/api/documents.info", {
body: { shareId: share.id, apiVersion: 2 },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.document.id).toEqual(document.id);
expect(body.data.document.createdBy).toEqual(undefined);
expect(body.data.document.updatedBy).toEqual(undefined);
expect(body.data.sharedTree).toEqual(undefined);
await share.reload();
expect(share.lastAccessedAt).toBeTruthy();
});
it("should not return details for nested documents", async () => {
const { document, collection, user } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: false,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/documents.info", {
body: { shareId: share.id, id: childDocument.id, apiVersion: 2 },
});
expect(res.status).toEqual(403);
});
});
it("should not return document from shareId if sharing is disabled for team", async () => { it("should not return document from shareId if sharing is disabled for team", async () => {
const { document, team, user } = await seed(); const { document, team, user } = await seed();
const share = await buildShare({ const share = await buildShare({

View File

@ -13,11 +13,12 @@ const { authorize } = policy;
const router = new Router(); const router = new Router();
router.post("shares.info", auth(), async (ctx) => { router.post("shares.info", auth(), async (ctx) => {
const { id, documentId } = ctx.body; const { id, documentId, apiVersion } = ctx.body;
ctx.assertUuid(id || documentId, "id or documentId is required"); ctx.assertUuid(id || documentId, "id or documentId is required");
const user = ctx.state.user; const user = ctx.state.user;
const share = await Share.findOne({ let shares = [];
let share = await Share.findOne({
where: id where: id
? { ? {
id, id,
@ -29,15 +30,62 @@ router.post("shares.info", auth(), async (ctx) => {
revokedAt: { [Op.eq]: null }, revokedAt: { [Op.eq]: null },
}, },
}); });
if (!share || !share.document) {
// Deprecated API response returns just the share for the current documentId
if (apiVersion !== 2) {
if (!share || !share.document) {
return (ctx.response.status = 204);
}
authorize(user, "read", share);
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
return;
}
// API version 2 returns the response for the current documentId and any
// parent documents that are publicly shared and accessible to the user
if (share && share.document) {
authorize(user, "read", share);
shares.push(share);
}
if (documentId) {
const document = await Document.scope("withCollection").findByPk(
documentId
);
const parentIds = document?.collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.findOne({
where: {
documentId: parentIds,
teamId: user.teamId,
revokedAt: { [Op.eq]: null },
includeChildDocuments: true,
published: true,
},
})
: undefined;
if (parentShare && parentShare.document) {
authorize(user, "read", parentShare);
shares.push(parentShare);
}
}
if (!shares.length) {
return (ctx.response.status = 204); return (ctx.response.status = 204);
} }
authorize(user, "read", share);
ctx.body = { ctx.body = {
data: presentShare(share, user.isAdmin), data: {
policies: presentPolicies(user, [share]), shares: shares.map((share) => presentShare(share, user.isAdmin)),
},
policies: presentPolicies(user, shares),
}; };
}); });
@ -95,15 +143,28 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
}); });
router.post("shares.update", auth(), async (ctx) => { router.post("shares.update", auth(), async (ctx) => {
const { id, published } = ctx.body; const { id, includeChildDocuments, published } = ctx.body;
ctx.assertUuid(id, "id is required"); ctx.assertUuid(id, "id is required");
ctx.assertPresent(published, "published is required");
const user = ctx.state.user; const { user } = ctx.state;
const share = await Share.findByPk(id); const share = await Share.findByPk(id);
authorize(user, "update", share); authorize(user, "update", share);
share.published = published; if (published !== undefined) {
share.published = published;
// Reset nested document sharing when unpublishing a share link. So that
// If it's ever re-published this doesn't immediately share nested docs
// without forewarning the user
if (!published) {
share.includeChildDocuments = false;
}
}
if (includeChildDocuments !== undefined) {
share.includeChildDocuments = includeChildDocuments;
}
await share.save(); await share.save();
await Event.create({ await Event.create({

View File

@ -2,7 +2,7 @@
import TestServer from "fetch-test-server"; import TestServer from "fetch-test-server";
import app from "../app"; import app from "../app";
import { CollectionUser } from "../models"; import { CollectionUser } from "../models";
import { buildUser, buildShare } from "../test/factories"; import { buildUser, buildDocument, buildShare } from "../test/factories";
import { flushdb, seed } from "../test/support"; import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback()); const server = new TestServer(app.callback());
@ -260,7 +260,7 @@ describe("#shares.info", () => {
expect(body.data.createdBy.id).toBe(user.id); expect(body.data.createdBy.id).toBe(user.id);
}); });
it("should allow reading share creaded by deleted user", async () => { it("should allow reading share created by deleted user", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
const author = await buildUser({ teamId: user.teamId }); const author = await buildUser({ teamId: user.teamId });
const share = await buildShare({ const share = await buildShare({
@ -347,6 +347,135 @@ describe("#shares.info", () => {
expect(res.status).toEqual(204); expect(res.status).toEqual(204);
}); });
describe("apiVersion=2", () => {
it("should allow reading share by documentId", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: document.id,
apiVersion: 2,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.shares.length).toBe(1);
expect(body.data.shares[0].id).toBe(share.id);
expect(body.data.shares[0].published).toBe(true);
});
it("should return share for parent document with includeChildDocuments=true", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: childDocument.id,
apiVersion: 2,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.shares.length).toBe(1);
expect(body.data.shares[0].id).toBe(share.id);
expect(body.data.shares[0].documentId).toBe(document.id);
expect(body.data.shares[0].published).toBe(true);
expect(body.data.shares[0].includeChildDocuments).toBe(true);
});
it("should not return share for parent document with includeChildDocuments=false", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: false,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: childDocument.id,
apiVersion: 2,
},
});
expect(res.status).toEqual(204);
});
it("should return shares for parent document and current document", async () => {
const { user, document, collection } = await seed();
const childDocument = await buildDocument({
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
});
const share = await buildShare({
documentId: childDocument.id,
teamId: user.teamId,
userId: user.id,
includeChildDocuments: false,
});
const share2 = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
includeChildDocuments: true,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: childDocument.id,
apiVersion: 2,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.shares.length).toBe(2);
expect(body.data.shares[0].id).toBe(share.id);
expect(body.data.shares[0].includeChildDocuments).toBe(false);
expect(body.data.shares[0].documentId).toBe(childDocument.id);
expect(body.data.shares[0].published).toBe(true);
expect(body.data.shares[1].id).toBe(share2.id);
expect(body.data.shares[1].documentId).toBe(document.id);
expect(body.data.shares[1].published).toBe(true);
expect(body.data.shares[1].includeChildDocuments).toBe(true);
});
});
it("should require authentication", async () => { it("should require authentication", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
const share = await buildShare({ const share = await buildShare({

View File

@ -2,7 +2,7 @@
module.exports = { module.exports = {
up: async (queryInterface, Sequelize) => { up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("shares", "lastAccessedAt", { await queryInterface.addColumn("shares", "lastAccessedAt", {
type: Sequelize.DATE, type: Sequelize.DATE,
allowNull: true, allowNull: true,
}); });

View File

@ -0,0 +1,15 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("shares", "includeChildDocuments", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("shares", "includeChildDocuments");
}
};

View File

@ -375,6 +375,77 @@ Collection.prototype.deleteDocument = async function (document) {
await document.deleteWithChildren(); await document.deleteWithChildren();
}; };
Collection.prototype.isChildDocument = function (
parentDocumentId,
documentId
): boolean {
let result = false;
const loopChildren = (documents, input) => {
return documents.map((document) => {
let parents = [...input];
if (document.id === documentId) {
result = parents.includes(parentDocumentId);
} else {
parents.push(document.id);
loopChildren(document.children, parents);
}
return document;
});
};
loopChildren(this.documentStructure, []);
return result;
};
Collection.prototype.getDocumentTree = function (documentId: string) {
let result;
const loopChildren = (documents) => {
if (result) {
return;
}
documents.forEach((document) => {
if (result) {
return;
}
if (document.id === documentId) {
result = document;
} else {
loopChildren(document.children);
}
});
};
loopChildren(this.documentStructure);
return result;
};
Collection.prototype.getDocumentParents = function (
documentId: string
): string[] | void {
let result;
const loopChildren = (documents, path = []) => {
if (result) {
return;
}
documents.forEach((document) => {
if (document.id === documentId) {
result = path;
} else {
loopChildren(document.children, [...path, document.id]);
}
});
};
loopChildren(this.documentStructure);
return result;
};
Collection.prototype.removeDocumentInStructure = async function ( Collection.prototype.removeDocumentInStructure = async function (
document, document,
options options

View File

@ -6,6 +6,7 @@ import {
buildGroup, buildGroup,
buildCollection, buildCollection,
buildTeam, buildTeam,
buildDocument,
} from "../test/factories"; } from "../test/factories";
import { flushdb, seed } from "../test/support"; import { flushdb, seed } from "../test/support";
@ -19,6 +20,134 @@ describe("#url", () => {
}); });
}); });
describe("getDocumentParents", () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [
{
...parent.toJSON(),
children: [document.toJSON()],
},
],
});
const result = collection.getDocumentParents(document.id);
expect(result.length).toBe(1);
expect(result[0]).toBe(parent.id);
});
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [
{
...parent.toJSON(),
children: [document.toJSON()],
},
],
});
const result = collection.getDocumentParents(parent.id);
expect(result.length).toBe(0);
});
});
describe("getDocumentTree", () => {
test("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [document.toJSON()],
});
expect(collection.getDocumentTree(document.id)).toEqual(document.toJSON());
});
test("should return nested documents in tree", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [
{
...parent.toJSON(),
children: [document.toJSON()],
},
],
});
expect(collection.getDocumentTree(parent.id)).toEqual({
...parent.toJSON(),
children: [document.toJSON()],
});
expect(collection.getDocumentTree(document.id)).toEqual(document.toJSON());
});
});
describe("isChildDocument", () => {
test("should return false with unexpected data", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [document.toJSON()],
});
expect(collection.isChildDocument(document.id, document.id)).toEqual(false);
expect(collection.isChildDocument(document.id, undefined)).toEqual(false);
expect(collection.isChildDocument(undefined, document.id)).toEqual(false);
});
test("should return false if sibling", async () => {
const one = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [one.toJSON(), document.toJSON()],
});
expect(collection.isChildDocument(one.id, document.id)).toEqual(false);
expect(collection.isChildDocument(document.id, one.id)).toEqual(false);
});
test("should return true if direct child of parent", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [
{
...parent.toJSON(),
children: [document.toJSON()],
},
],
});
expect(collection.isChildDocument(parent.id, document.id)).toEqual(true);
expect(collection.isChildDocument(document.id, parent.id)).toEqual(false);
});
test("should return true if nested child of parent", async () => {
const parent = await buildDocument();
const nested = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [
{
...parent.toJSON(),
children: [
{
...nested.toJSON(),
children: [document.toJSON()],
},
],
},
],
});
expect(collection.isChildDocument(parent.id, document.id)).toEqual(true);
expect(collection.isChildDocument(document.id, parent.id)).toEqual(false);
});
});
describe("#addDocumentToStructure", () => { describe("#addDocumentToStructure", () => {
test("should add as last element without index", async () => { test("should add as last element without index", async () => {
const { collection } = await seed(); const { collection } = await seed();

View File

@ -10,6 +10,7 @@ const Share = sequelize.define(
primaryKey: true, primaryKey: true,
}, },
published: DataTypes.BOOLEAN, published: DataTypes.BOOLEAN,
includeChildDocuments: DataTypes.BOOLEAN,
revokedAt: DataTypes.DATE, revokedAt: DataTypes.DATE,
revokedById: DataTypes.UUID, revokedById: DataTypes.UUID,
lastAccessedAt: DataTypes.DATE, lastAccessedAt: DataTypes.DATE,

View File

@ -11,6 +11,7 @@ export default function present(share: Share, isAdmin: boolean = false) {
published: share.published, published: share.published,
url: `${share.team.url}/share/${share.id}`, url: `${share.team.url}/share/${share.id}`,
createdBy: presentUser(share.user), createdBy: presentUser(share.user),
includeChildDocuments: share.includeChildDocuments,
lastAccessedAt: share.lastAccessedAt, lastAccessedAt: share.lastAccessedAt,
createdAt: share.createdAt, createdAt: share.createdAt,
updatedAt: share.updatedAt, updatedAt: share.updatedAt,

View File

@ -3,17 +3,17 @@
"currently viewing": "currently viewing", "currently viewing": "currently viewing",
"previously edited": "previously edited", "previously edited": "previously edited",
"You": "You", "You": "You",
"Trash": "Trash",
"Archive": "Archive",
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"Viewers": "Viewers", "Viewers": "Viewers",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection", "Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description", "Add a description": "Add a description",
"Collapse": "Collapse", "Collapse": "Collapse",
"Expand": "Expand", "Expand": "Expand",
"Submenu": "Submenu", "Submenu": "Submenu",
"Trash": "Trash",
"Archive": "Archive",
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"New": "New", "New": "New",
"Only visible to you": "Only visible to you", "Only visible to you": "Only visible to you",
"Draft": "Draft", "Draft": "Draft",
@ -290,13 +290,18 @@
"New from template": "New from template", "New from template": "New from template",
"Publish": "Publish", "Publish": "Publish",
"Publishing": "Publishing", "Publishing": "Publishing",
"Nested documents": "Nested documents",
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document", "Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
"Share": "Share", "Share": "Share",
"Share this document": "Share this document", "Share this document": "Share this document",
"This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared": "This document is shared because the parent <em>{{ documentTitle }}</em> is publicly shared",
"Publish to internet": "Publish to internet", "Publish to internet": "Publish to internet",
"Anyone with the link can view this document": "Anyone with the link can view this document", "Anyone with the link can view this document": "Anyone with the link can view this document",
"Only team members with access can view": "Only team members with access can view", "Only team members with permission can view": "Only team members with permission can view",
"The shared link was last accessed {{ timeAgo }}.": "The shared link was last accessed {{ timeAgo }}.", "The shared link was last accessed {{ timeAgo }}.": "The shared link was last accessed {{ timeAgo }}.",
"Share nested documents": "Share nested documents",
"Nested documents are publicly available": "Nested documents are publicly available",
"Nested documents are not shared": "Nested documents are not shared",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?", "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.", "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.", "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",