feat: Public sharing of collections (#9529)

* shares.info, collections.info, documents.info

* shares.list, shares.create, shares.update

* shares.sitemap

* parity with existing document shared screen

* collection share popover

* parent share and table

* collection scene

* collection link in sidebar

* sidebar and breadcrumb collection link click

* collection link click in editor

* meta

* more meta + 404 page

* map internal link, remove showLastUpdated option

* fix shares.list pagination

* show last updated

* shareLoader tests

* lint

* sidebar context for collection link

* badge in shares table

* fix existing tests

* tsc

* update failing test snapshot

* env

* signed url for collection attachments

* include collection content in SSR for screen readers

* search

* drafts can be shared

* review

* tsc, remove old shared-doc scene

* tweaks

* DRY

* refactor loader

* Remove share/collection urls

* fix: Collection overview should not be editable when viewing shared link and logged in

* Tweak public breadcrumb

* fix: Deleted documents should never be exposed through share

* empty sharedTree array where includeChildDocuments is false

* revert includeChildDocs guard for logical correctness + SSR bug fix

* fix: check document is part of share

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Hemachandar
2025-08-03 22:37:39 +05:30
committed by GitHub
parent f7826c7d91
commit d3eb3db7ba
55 changed files with 2237 additions and 708 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ import breakpoint from "styled-components-breakpoint";
import { s, hover, ellipsis } from "@shared/styles";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { sharedModelPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -51,7 +51,7 @@ function DocumentListItem(
dir={document.dir}
to={{
pathname: shareId
? sharedDocumentPath(shareId, document.url)
? sharedModelPath(shareId, document.url)
: document.url,
state: {
title: document.titleWithDefault,
@@ -5,32 +5,42 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Share from "~/models/Share";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import Scrollable from "~/components/Scrollable";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import { PublicAccess } from "./PublicAccess";
type Props = {
/** Collection to which team members are supposed to be invited */
collection: Collection;
/** The existing share model, if any. */
share: Share | null | undefined;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users and groups that have been invited during the current editing session */
invitedInSession: string[];
/** Whether the popover is visible. */
visible: boolean;
};
export const AccessControlList = observer(
({ collection, invitedInSession }: Props) => {
({ collection, share, invitedInSession, visible }: Props) => {
const { memberships, groupMemberships } = useStores();
const team = useCurrentTeam();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
@@ -246,6 +256,12 @@ export const AccessControlList = observer(
))}
</>
)}
{team.sharing && can.share && collection.sharing && visible && (
<Sticky>
{collection.members.length ? <Separator /> : null}
<PublicAccess collection={collection} share={share} />
</Sticky>
)}
</ScrollableContainer>
);
}
@@ -255,3 +271,9 @@ const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;
const Sticky = styled.div`
background: ${s("menuBackground")};
position: sticky;
bottom: 0;
`;
@@ -0,0 +1,267 @@
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Collection from "~/models/Collection";
import Share from "~/models/Share";
import { AvatarSize } from "~/components/Avatar";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import Input, { NativeInput } from "~/components/Input";
import NudeButton from "~/components/NudeButton";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import { ListItem } from "../components/ListItem";
type Props = {
/** The collection to share. */
collection: Collection;
/** The existing share model, if any. */
share: Share | null | undefined;
};
function InnerPublicAccess({ collection, share }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [validationError, setValidationError] = useState("");
const [urlId, setUrlId] = useState(share?.urlId);
const inputRef = useRef<HTMLInputElement>(null);
const can = usePolicy(share);
const collectionAbilities = usePolicy(collection);
const canPublish = can.update && collectionAbilities.share;
useEffect(() => {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
allowIndexing: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleShowLastModifiedChanged = useCallback(
async (checked: boolean) => {
try {
await share?.save({
showLastUpdated: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = useCallback(
async (checked: boolean) => {
try {
await share?.save({
published: checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handleUrlChange = useMemo(
() =>
debounce(async (ev) => {
if (!share) {
return;
}
const val = ev.target.value;
setUrlId(val);
if (val && !UrlHelper.SHARE_URL_SLUG_REGEX.test(val)) {
setValidationError(
t("Only lowercase letters, digits and dashes allowed")
);
} else {
setValidationError("");
if (share.urlId !== val) {
try {
await share.save({
urlId: isEmpty(val) ? null : val,
});
} catch (err) {
if (err.message.includes("must be unique")) {
setValidationError(t("Sorry, this link has already been used"));
}
}
}
}
}, 500),
[t, share]
);
const handleCopied = useCallback(() => {
toast.success(t("Public link copied to clipboard"));
}, [t]);
const copyButton = (
<Tooltip content={t("Copy public link")} placement="top">
<CopyToClipboard text={share?.url ?? ""} onCopy={handleCopied}>
<NudeButton type="button" disabled={!share} style={{ marginRight: 3 }}>
<CopyIcon color={theme.placeholder} size={18} />
</NudeButton>
</CopyToClipboard>
</Tooltip>
);
return (
<Wrapper>
<ListItem
title={t("Web")}
subtitle={<>{t("Allow anyone with the link to access")}</>}
image={
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GlobeIcon color={theme.background} size={18} />
</Squircle>
}
actions={
<Switch
aria-label={t("Publish to internet")}
checked={share?.published ?? false}
onChange={handlePublishedChange}
disabled={!canPublish}
width={26}
height={14}
/>
}
/>
<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"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
<ShareLinkInput
type="text"
ref={inputRef}
placeholder={share?.id}
onChange={handleUrlChange}
error={validationError}
defaultValue={urlId}
prefix={
<DomainPrefix onClick={() => inputRef.current?.focus()}>
{env.URL.replace(/https?:\/\//, "") + "/s/"}
</DomainPrefix>
}
>
{copyButton}
</ShareLinkInput>
<Flex align="flex-start" gap={4}>
<StyledInfoIcon size={18} color={theme.textTertiary} />
<Text type="tertiary" size="xsmall">
{t(
"All documents in this collection will be shared on the web, including any new documents added later"
)}
.
</Text>
</Flex>
</>
)}
</ResizingHeightContainer>
</Wrapper>
);
}
const Wrapper = styled.div`
padding-bottom: 8px;
`;
const DomainPrefix = styled.span`
padding: 0 2px 0 8px;
flex: 0 1 auto;
cursor: text;
color: ${s("placeholder")};
user-select: none;
`;
const ShareLinkInput = styled(Input)`
margin-top: 12px;
min-width: 100px;
flex: 1;
${NativeInput}:not(:first-child) {
padding: 4px 8px 4px 0;
flex: 1;
}
`;
const StyledInfoIcon = styled(InfoIcon)`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const PublicAccess = observer(InnerPublicAccess);
@@ -39,7 +39,7 @@ type Props = {
function SharePopover({ collection, visible, onRequestClose }: Props) {
const team = useCurrentTeam();
const { groupMemberships, users, groups, memberships } = useStores();
const { groupMemberships, users, groups, memberships, shares } = useStores();
const { t } = useTranslation();
const can = usePolicy(collection);
const [query, setQuery] = React.useState("");
@@ -51,6 +51,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
CollectionPermission.Read
);
const share = shares.getByCollectionId(collection.id);
const prevPendingIds = usePrevious(pendingIds);
const suggestionsRef = React.useRef<HTMLDivElement | null>(null);
@@ -93,9 +94,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
React.useEffect(() => {
if (visible) {
void collection.share();
setHasRendered(true);
}
}, [visible]);
}, [collection, visible]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
@@ -363,7 +365,9 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
<div style={{ display: picker ? "none" : "block" }}>
<AccessControlList
collection={collection}
share={share}
invitedInSession={invitedInSession}
visible={visible}
/>
</div>
</Wrapper>
@@ -125,8 +125,6 @@ function PublicAccess({ document, share, sharedParent }: Props) {
toast.success(t("Public link copied to clipboard"));
}, [t]);
const documentTitle = sharedParent?.documentTitle;
const shareUrl = sharedParent?.url
? `${sharedParent.url}${document.url}`
: (share?.url ?? "");
@@ -148,13 +146,24 @@ function PublicAccess({ document, share, sharedParent }: Props) {
subtitle={
<>
{sharedParent && !document.isDraft ? (
<Trans>
Anyone with the link can access because the parent document,{" "}
<StyledLink to={`/doc/${sharedParent.documentId}`}>
{{ documentTitle }}
</StyledLink>
, is shared
</Trans>
sharedParent.collectionId ? (
<Trans>
Anyone with the link can access because the containing
collection,{" "}
<StyledLink to={`/collection/${sharedParent.collectionId}`}>
{sharedParent.sourceTitle}
</StyledLink>
, is shared
</Trans>
) : (
<Trans>
Anyone with the link can access because the parent document,{" "}
<StyledLink to={`/doc/${sharedParent.documentId}`}>
{sharedParent.sourceTitle}
</StyledLink>
, is shared
</Trans>
)
) : (
t("Allow anyone with the link to access")
)}
@@ -180,60 +189,59 @@ 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"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
)}
{share?.published && (
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
{share?.published && !sharedParent?.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"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Show last modified")}&nbsp;
<Tooltip
content={t(
"Display the last modified timestamp on the shared page"
)}
>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Show last modified")}
checked={share?.showLastUpdated ?? false}
onChange={handleShowLastModifiedChanged}
width={26}
height={14}
/>
}
/>
</>
)}
{sharedParent?.published ? (
@@ -43,7 +43,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
const can = usePolicy(document);
const { shares } = useStores();
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document.id);
const sharedParent = shares.getByDocumentParents(document);
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships, groups, groupMemberships } = useStores();
const [query, setQuery] = React.useState("");
@@ -0,0 +1,34 @@
import { observer } from "mobx-react";
import { MoonIcon, SunIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
import { Theme } from "~/stores/UiStore";
export const AppearanceAction = observer(() => {
const { t } = useTranslation();
const { ui } = useStores();
const { resolvedTheme } = ui;
return (
<Action>
<Tooltip
content={
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
}
placement="bottom"
>
<Button
icon={resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
onClick={() =>
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
}
neutral
borderOnHover
/>
</Tooltip>
</Action>
);
});
+26 -19
View File
@@ -3,8 +3,8 @@ import { SidebarIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { metaDisplay } from "@shared/utils/keyboard";
import Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
@@ -12,28 +12,33 @@ import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
import { homePath, sharedModelPath } from "~/utils/routeHelpers";
import { AvatarSize } from "../Avatar";
import { useTeamContext } from "../TeamContext";
import TeamLogo from "../TeamLogo";
import Sidebar from "./Sidebar";
import Section from "./components/Section";
import DocumentLink from "./components/SharedDocumentLink";
import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
type Props = {
rootNode: NavigationNode;
shareId: string;
share: Share;
};
function SharedSidebar({ rootNode, shareId }: Props) {
function SharedSidebar({ share }: Props) {
const team = useTeamContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents } = useStores();
const { t } = useTranslation();
const teamAvailable = !!team?.name;
const rootNode = share.tree;
if (!rootNode?.children.length) {
return null;
}
return (
<StyledSidebar $hoverTransition={!teamAvailable}>
@@ -44,9 +49,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
onClick={() =>
history.push(
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
)
history.push(user ? homePath() : sharedModelPath(share.id))
}
>
<ToggleSidebar />
@@ -55,7 +58,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
<ScrollContainer topShadow flex>
<TopSection>
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
<StyledSearchPopover shareId={share.id} />
</SearchWrapper>
{!teamAvailable && (
<ToggleWrapper>
@@ -64,15 +67,19 @@ function SharedSidebar({ rootNode, shareId }: Props) {
)}
</TopSection>
<Section>
<DocumentLink
index={0}
depth={0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
{share.collectionId ? (
<SharedCollectionLink node={rootNode} shareId={share.id} />
) : (
<SharedDocumentLink
index={0}
depth={0}
shareId={share.id}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
)}
</Section>
</ScrollContainer>
</StyledSidebar>
@@ -0,0 +1,53 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import useStores from "~/hooks/useStores";
import { sharedModelPath } from "~/utils/routeHelpers";
import { SharedDocumentLink } from "./SharedDocumentLink";
import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
shareId: string;
};
function CollectionLink({ node, shareId }: Props) {
const { t } = useTranslation();
const { documents, ui } = useStores();
const icon = node.icon ?? node.emoji;
return (
<>
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={icon && <Icon value={icon} color={node.color} />}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
{node.children.map((childNode, index) => (
<SharedDocumentLink
key={childNode.id}
index={index}
depth={2}
shareId={shareId}
node={childNode}
prefetchDocument={documents.prefetchDocument}
activeDocumentId={ui.activeDocumentId}
activeDocument={documents.active}
/>
))}
</>
);
}
export const SharedCollectionLink = observer(CollectionLink);
@@ -7,7 +7,7 @@ import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import useStores from "~/hooks/useStores";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { sharedModelPath } from "~/utils/routeHelpers";
import { descendants } from "~/utils/tree";
import SidebarLink from "./SidebarLink";
@@ -113,7 +113,7 @@ function DocumentLink(
<>
<SidebarLink
to={{
pathname: sharedDocumentPath(shareId, node.url),
pathname: sharedModelPath(shareId, node.url),
state: {
title: node.title,
},
@@ -132,7 +132,7 @@ function DocumentLink(
/>
{expanded &&
nodeChildren.map((childNode, index) => (
<ObservedDocumentLink
<SharedDocumentLink
shareId={shareId}
key={childNode.id}
collection={collection}
@@ -150,6 +150,4 @@ function DocumentLink(
);
}
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
export default ObservedDocumentLink;
export const SharedDocumentLink = observer(React.forwardRef(DocumentLink));
+4 -3
View File
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { useHistory } from "react-router-dom";
import { isModKey } from "@shared/utils/keyboard";
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { sharedModelPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import useStores from "./useStores";
@@ -49,10 +49,11 @@ export default function useEditorClickHandlers({ shareId }: Params) {
if (
shareId &&
(!linkShareId || linkShareId === shareId) &&
navigateTo.includes("/doc/") &&
(navigateTo.includes("/doc/") ||
navigateTo.includes("/collection/")) &&
!navigateTo.includes(shareId)
) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
navigateTo = sharedModelPath(shareId, navigateTo);
}
if (isDocumentUrl(navigateTo)) {
+7 -4
View File
@@ -26,10 +26,13 @@ function ShareMenu({ share }: Props) {
const history = useHistory();
const can = usePolicy(share);
const handleGoToDocument = React.useCallback(
const handleGoToSource = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
history.push(share.documentUrl);
history.push({
pathname: share.sourcePathWithFallback,
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
});
},
[history, share]
);
@@ -61,8 +64,8 @@ function ShareMenu({ share }: Props) {
{t("Copy link")}
</MenuItem>
</CopyToClipboard>
<MenuItem {...menu} onClick={handleGoToDocument} icon={<ArrowIcon />}>
{t("Go to document")}
<MenuItem {...menu} onClick={handleGoToSource} icon={<ArrowIcon />}>
{share.collectionId ? t("Go to collection") : t("Go to document")}
</MenuItem>
{can.revoke && (
<>
+7
View File
@@ -320,6 +320,13 @@ export default class Collection extends ParanoidModel {
this.index = index;
}
@action
share = async () =>
this.store.rootStore.shares.create({
type: "collection",
collectionId: this.id,
});
getChildrenForDocument(documentId: string) {
let result: NavigationNode[] = [];
+2 -1
View File
@@ -330,7 +330,7 @@ export default class Document extends ArchivableModel implements Searchable {
get isPubliclyShared(): boolean {
const { shares, auth } = this.store.rootStore;
const share = shares.getByDocumentId(this.id);
const sharedParent = shares.getByDocumentParents(this.id);
const sharedParent = shares.getByDocumentParents(this);
return !!(
auth.team?.sharing !== false &&
@@ -461,6 +461,7 @@ export default class Document extends ArchivableModel implements Searchable {
@action
share = async () =>
this.store.rootStore.shares.create({
type: "document",
documentId: this.id,
});
+36 -1
View File
@@ -1,4 +1,7 @@
import { computed, observable } from "mobx";
import { NavigationNode, PublicTeam } from "@shared/types";
import SharesStore from "~/stores/SharesStore";
import env from "~/env";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
@@ -10,6 +13,8 @@ import { Searchable } from "./interfaces/Searchable";
class Share extends Model implements Searchable {
static modelName = "Share";
store: SharesStore;
@Field
@observable
published: boolean;
@@ -44,6 +49,12 @@ class Share extends Model implements Searchable {
@observable
domain: string;
@observable
sourceTitle: string;
@observable
sourcePath: string;
@observable
documentTitle: string;
@@ -71,9 +82,33 @@ class Share extends Model implements Searchable {
@Relation(() => User, { onDelete: "null" })
createdBy: User;
static sitemapUrl(shareId: string) {
return `${env.URL}/api/shares.sitemap?shareId=${shareId}`;
}
@computed
get title(): string {
return this.sourceTitle ?? this.documentTitle;
}
@computed
get sourcePathWithFallback(): string {
return this.sourcePath ?? this.documentUrl;
}
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
return [this.title];
}
@computed
get team(): PublicTeam | undefined {
return this.store.sharedCache.get(this.id)?.team;
}
@computed
get tree(): NavigationNode | undefined {
return this.store.sharedCache.get(this.id)?.sharedTree ?? undefined;
}
}
+14 -11
View File
@@ -7,11 +7,14 @@ import Route from "~/components/ProfiledRoute";
import env from "~/env";
import useQueryNotices from "~/hooks/useQueryNotices";
import lazy from "~/utils/lazyWithRetry";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
import {
matchCollectionSlug as collectionSlug,
matchDocumentSlug as documentSlug,
} from "~/utils/routeHelpers";
const Authenticated = lazy(() => import("~/components/Authenticated"));
const AuthenticatedRoutes = lazy(() => import("./authenticated"));
const SharedDocument = lazy(() => import("~/scenes/Document/Shared"));
const Shared = lazy(() => import("~/scenes/Shared"));
const Login = lazy(() => import("~/scenes/Login"));
const Logout = lazy(() => import("~/scenes/Logout"));
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
@@ -29,13 +32,13 @@ export default function Routes() {
>
{env.ROOT_SHARE_ID ? (
<Switch>
<Route exact path="/" component={SharedDocument} />
<Route exact path={`/doc/${slug}`} component={SharedDocument} />
<Route exact path="/" component={Shared} />
<Route exact path={`/doc/${documentSlug}`} component={Shared} />
<Redirect exact from="/s/:shareId" to="/" />
<Redirect
exact
from={`/s/:shareId/doc/${slug}`}
to={`/doc/${slug}`}
from={`/s/:shareId/doc/${documentSlug}`}
to={`/doc/${documentSlug}`}
/>
</Switch>
) : (
@@ -47,17 +50,17 @@ export default function Routes() {
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />
<Route exact path="/s/:shareId" component={Shared} />
<Redirect
exact
from={`/share/:shareId/doc/${slug}`}
to={`/s/:shareId/doc/${slug}`}
from={`/share/:shareId/doc/${documentSlug}`}
to={`/s/:shareId/doc/${documentSlug}`}
/>
<Route
exact
path={`/s/:shareId/doc/${slug}`}
component={SharedDocument}
path={`/s/:shareId/doc/${documentSlug}`}
component={Shared}
/>
<Authenticated>
@@ -23,12 +23,13 @@ const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
shareId?: string;
};
function Overview({ collection }: Props) {
function Overview({ collection, shareId }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const user = useCurrentUser({ rejectOnEmpty: false });
const can = usePolicy(collection);
const handleSave = useMemo(
@@ -88,9 +89,10 @@ function Overview({ collection }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
readOnly={!can.update || !!shareId}
userId={user?.id}
editorStyle={editorStyle}
shareId={shareId}
/>
<div ref={childRef} />
</Suspense>
+12 -1
View File
@@ -38,6 +38,7 @@ import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NotFoundError } from "~/utils/errors";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
@@ -65,7 +66,7 @@ const CollectionScene = observer(function _CollectionScene() {
const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation();
const { documents, collections, ui } = useStores();
const { documents, collections, shares, ui } = useStores();
const [error, setError] = useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
@@ -130,6 +131,16 @@ const CollectionScene = observer(function _CollectionScene() {
void fetchData();
}, []);
useEffect(() => {
if (collection) {
shares.fetchOne({ collectionId: collection.id }).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
}
}, [shares, collection]);
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
if (!collection && error) {
-262
View File
@@ -1,262 +0,0 @@
import { Location } from "history";
import { observer } from "mobx-react";
import { useMemo, useState, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { RouteComponentProps, useLocation } from "react-router-dom";
import styled, { ThemeProvider } from "styled-components";
import { s } from "@shared/styles";
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import {
DocumentContextProvider,
useDocumentContext,
} from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import { TeamContext } from "~/components/TeamContext";
import Text from "~/components/Text";
import env from "~/env";
import useBuildTheme from "~/hooks/useBuildTheme";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import {
AuthorizationError,
NotFoundError,
OfflineError,
} from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import ErrorUnknown from "../Errors/ErrorUnknown";
import Login from "../Login";
import Document from "./components/Document";
import Loading from "./components/Loading";
const EMPTY_OBJECT = {};
type Response = {
document?: DocumentModel;
team?: PublicTeam;
sharedTree?: NavigationNode | undefined;
};
type Props = RouteComponentProps<{
shareId: string;
documentSlug: string;
}> & {
location: Location<{ title?: string }>;
};
// Parse the canonical origin from the SSR HTML, only needs to be done once.
const canonicalUrl = document
.querySelector("link[rel=canonical]")
?.getAttribute("href");
const canonicalOrigin = canonicalUrl
? new URL(canonicalUrl).origin
: window.location.origin;
/**
* Find the document UUID from the slug given the sharedTree
*
* @param documentSlug The slug from the url
* @param response The response payload from the server
* @returns The document UUID, if found.
*/
function useDocumentId(documentSlug: string, response?: Response) {
let documentId;
function findInTree(node: NavigationNode) {
if (node.url.endsWith(documentSlug)) {
documentId = node.id;
return true;
}
if (node.children) {
for (const child of node.children) {
if (findInTree(child)) {
return true;
}
}
}
return false;
}
if (response?.sharedTree) {
findInTree(response.sharedTree);
}
return documentId;
}
function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const location = useLocation();
const user = useCurrentUser({ rejectOnEmpty: false });
const searchParams = useMemo(
() => new URLSearchParams(location.search),
[location.search]
);
const { t, i18n } = useTranslation();
const [response, setResponse] = useState<Response>();
const [error, setError] = useState<Error | null | undefined>();
const { documents } = useStores();
const [, setPostLoginPath] = usePostLoginPath();
const { shareId = env.ROOT_SHARE_ID, documentSlug } = props.match.params;
const documentId = useDocumentId(documentSlug, response);
const themeOverride = ["dark", "light"].includes(
searchParams.get("theme") || ""
)
? (searchParams.get("theme") as Theme)
: undefined;
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
useEffect(() => {
if (shareId) {
client.setShareId(shareId);
}
return () => {
client.setShareId(undefined);
};
}, [shareId]);
useEffect(() => {
if (!user) {
void changeLanguage(detectLanguage(), i18n);
}
}, [user, i18n]);
// ensure the wider page color always matches the theme
useEffect(() => {
window.document.body.style.background = theme.background;
}, [theme]);
useEffect(() => {
if (documentId) {
ui.setActiveDocument(documentId);
}
}, [ui, documentId]);
useEffect(() => {
async function fetchData() {
try {
setResponse((state) => ({
...state,
document: undefined,
}));
const res = await documents.fetchWithSharedTree(documentSlug, {
shareId,
});
setResponse(res);
} catch (err) {
setError(err);
}
}
void fetchData();
}, [documents, documentSlug, shareId, ui]);
if (error) {
if (error instanceof OfflineError) {
return <ErrorOffline />;
} else if (error instanceof AuthorizationError) {
setPostLoginPath(props.location.pathname);
return (
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<Content>
{t(
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
{
teamName: config.name,
appName: env.APP_NAME,
}
)}
</Content>
) : null
}
</Login>
);
} else if (error instanceof NotFoundError) {
return <Error404 />;
} else {
return <ErrorUnknown />;
}
}
// Note: `sharedTree` will be null when `includeChildDocuments` = false
if (response?.sharedTree === undefined) {
return <Loading location={props.location} />;
}
return (
<>
<Helmet>
<link
rel="canonical"
href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
/>
<link
rel="sitemap"
type="application/xml"
href={`${env.URL}/api/documents.sitemap?shareId=${shareId}`}
/>
</Helmet>
<TeamContext.Provider value={response.team}>
<ThemeProvider theme={theme}>
<DocumentContextProvider>
<Layout
title={response.document?.title}
sidebar={
response.sharedTree?.children.length ? (
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
) : undefined
}
>
<SharedDocument shareId={shareId} response={response} />
</Layout>
</DocumentContextProvider>
</ThemeProvider>
</TeamContext.Provider>
</>
);
}
const SharedDocument = observer(
({ shareId, response }: { shareId?: string; response: Response }) => {
const { hasHeadings, setDocument } = useDocumentContext();
if (!response.document) {
return null;
}
const tocPosition = hasHeadings
? (response.team?.tocPosition ?? TOCPosition.Left)
: false;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
}
);
const Content = styled(Text)`
color: ${s("textSecondary")};
text-align: center;
margin-top: -8px;
`;
export default observer(SharedDocumentScene);
@@ -195,7 +195,7 @@ function DataLoader({ match, children }: Props) {
});
}
shares.fetch(document.id).catch((err) => {
shares.fetchOne({ documentId: document.id }).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
+2 -24
View File
@@ -3,9 +3,7 @@ import {
TableOfContentsIcon,
EditIcon,
PlusIcon,
MoonIcon,
MoreIcon,
SunIcon,
} from "outline-icons";
import { useRef, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -15,7 +13,6 @@ import Icon from "@shared/components/Icon";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { NavigationNode } from "@shared/types";
import { altDisplay, metaDisplay } from "@shared/utils/keyboard";
import { Theme } from "~/stores/UiStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { Action, Separator } from "~/components/Actions";
@@ -48,6 +45,7 @@ import { documentEditPath } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
import { AppearanceAction } from "~/components/Sharing/components/Actions";
type Props = {
document: Document;
@@ -87,7 +85,6 @@ function DocumentHeader({
const theme = useTheme();
const team = useCurrentTeam({ rejectOnEmpty: false });
const user = useCurrentUser({ rejectOnEmpty: false });
const { resolvedTheme } = ui;
const isMobileMedia = useMobile();
const isRevision = !!revision;
const isEditingFocus = useEditingFocus();
@@ -173,25 +170,6 @@ function DocumentHeader({
</Tooltip>
</Action>
);
const appearanceAction = (
<Action>
<Tooltip
content={
resolvedTheme === "light" ? t("Switch to dark") : t("Switch to light")
}
placement="bottom"
>
<Button
icon={resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
onClick={() =>
ui.setTheme(resolvedTheme === "light" ? Theme.Dark : Theme.Light)
}
neutral
borderOnHover
/>
</Tooltip>
</Action>
);
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.key === "˙",
@@ -232,7 +210,7 @@ function DocumentHeader({
}
actions={
<>
{appearanceAction}
<AppearanceAction />
{can.update && !isEditing ? editAction : <div />}
</>
}
@@ -3,7 +3,7 @@ import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import Breadcrumb from "~/components/Breadcrumb";
import { MenuInternalLink } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { sharedModelPath } from "~/utils/routeHelpers";
type Props = {
children?: React.ReactNode;
@@ -50,7 +50,7 @@ const PublicBreadcrumb: React.FC<Props> = ({
const items: MenuInternalLink[] = React.useMemo(
() =>
pathToDocument(sharedTree, documentId)
.slice(0, -1)
.slice(1, -1)
.map((item) => ({
...item,
icon: item.icon ? (
@@ -58,7 +58,7 @@ const PublicBreadcrumb: React.FC<Props> = ({
) : undefined,
title: item.title,
type: "route",
to: sharedDocumentPath(shareId, item.url),
to: sharedModelPath(shareId, item.url),
})),
[sharedTree, shareId, documentId]
);
@@ -9,7 +9,7 @@ import { determineIconType } from "@shared/utils/icon";
import Document from "~/models/Document";
import Flex from "~/components/Flex";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { sharedModelPath } from "~/utils/routeHelpers";
import useClickIntent from "~/hooks/useClickIntent";
import useStores from "~/hooks/useStores";
import { useCallback } from "react";
@@ -80,7 +80,7 @@ function ReferenceListItem({
onMouseLeave={handleMouseLeave}
to={{
pathname: shareId
? sharedDocumentPath(shareId, document.url)
? sharedModelPath(shareId, document.url)
: document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: {
@@ -24,7 +24,7 @@ function ShareButton({ document }: Props) {
const { shares } = useStores();
const isMobile = useMobile();
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document.id);
const sharedParent = shares.getByDocumentParents(document);
const domain = share?.domain || sharedParent?.domain;
const closePopover = useCallback(() => {
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import {
@@ -33,10 +34,15 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
{
type: "data",
id: "title",
header: t("Document"),
accessor: (share) => share.documentTitle || t("Untitled"),
header: t("Title"),
accessor: (share) => share.sourceTitle || t("Untitled"),
sortable: false,
component: (share) => <>{share.documentTitle || t("Untitled")}</>,
component: (share) => (
<>
{share.sourceTitle || t("Untitled")}
{share.collectionId ? <Badge>{t("Collection")}</Badge> : null}
</>
),
width: "4fr",
},
{
+107
View File
@@ -0,0 +1,107 @@
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconTitleWrapper } from "@shared/components/Icon";
import CollectionModel from "~/models/Collection";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import { collectionPath } from "~/utils/routeHelpers";
import Overview from "../Collection/components/Overview";
import { AppearanceAction } from "~/components/Sharing/components/Actions";
type Props = {
collection: CollectionModel;
shareId: string;
};
function SharedCollection({ collection, shareId }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const isMobile = useMobile();
const editAction = (
<Action>
<Tooltip content={t("Edit collection")} shortcut="e" placement="bottom">
<Button
as={Link}
icon={<EditIcon />}
to={{
pathname: collectionPath(collection.path, "overview"),
}}
neutral
>
{isMobile ? null : t("Edit")}
</Button>
</Tooltip>
</Action>
);
return (
<Scene
centered={false}
textTitle={collection.name}
left={<div />}
title={
<>
<CollectionIcon collection={collection} expanded />
&nbsp;{collection.name}
</>
}
actions={
<>
<AppearanceAction />
{can.update ? editAction : null}
</>
}
>
<CenteredContent withStickyHeader>
<Flex column>
<CollectionHeading>
<IconTitleWrapper>
<CollectionIcon collection={collection} size={40} expanded />
</IconTitleWrapper>
{collection.name}
</CollectionHeading>
{!!shareId && !!collection.updatedAt ? (
<SharedMeta type="tertiary">
{t("Last updated")}{" "}
<Time dateTime={collection.updatedAt} addSuffix />
</SharedMeta>
) : null}
</Flex>
<Overview collection={collection} shareId={shareId} />
</CenteredContent>
</Scene>
);
}
const CollectionHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
const SharedMeta = styled(Text)`
margin: -12px 0 2em 0;
font-size: 14px;
`;
export const Collection = observer(SharedCollection);
+37
View File
@@ -0,0 +1,37 @@
import { observer } from "mobx-react";
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
import DocumentModel from "~/models/Document";
import DocumentComponent from "~/scenes/Document/components/Document";
import { useDocumentContext } from "~/components/DocumentContext";
import { useTeamContext } from "~/components/TeamContext";
import { useMemo } from "react";
type Props = {
document: DocumentModel;
shareId: string;
sharedTree?: NavigationNode;
};
function SharedDocument({ document, shareId, sharedTree }: Props) {
const team = useTeamContext() as PublicTeam | undefined;
const { hasHeadings, setDocument } = useDocumentContext();
const abilities = useMemo(() => ({}), []);
const tocPosition = hasHeadings
? (team?.tocPosition ?? TOCPosition.Left)
: false;
setDocument(document);
return (
<DocumentComponent
abilities={abilities}
document={document}
sharedTree={sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
}
export const Document = observer(SharedDocument);
+257
View File
@@ -0,0 +1,257 @@
import { observer } from "mobx-react";
import { useCallback, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useLocation, useParams } from "react-router-dom";
import styled, { ThemeProvider } from "styled-components";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Share from "~/models/Share";
import Error404 from "~/scenes/Errors/Error404";
import ClickablePadding from "~/components/ClickablePadding";
import { DocumentContextProvider } from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import { TeamContext } from "~/components/TeamContext";
import Text from "~/components/Text";
import env from "~/env";
import useBuildTheme from "~/hooks/useBuildTheme";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import Loading from "../Document/components/Loading";
import ErrorOffline from "../Errors/ErrorOffline";
import Login from "../Login";
import { Collection as CollectionScene } from "./Collection";
import { Document as DocumentScene } from "./Document";
// Parse the canonical origin from the SSR HTML, only needs to be done once.
const canonicalUrl = document
.querySelector("link[rel=canonical]")
?.getAttribute("href");
const canonicalOrigin = canonicalUrl
? new URL(canonicalUrl).origin
: window.location.origin;
type PathParams = {
shareId: string;
collectionSlug?: string;
documentSlug?: string;
};
type LocationState = {
title?: string;
};
function useModel() {
const { collections, documents, shares } = useStores();
const { shareId, collectionSlug, documentSlug } = useParams<PathParams>();
if (collectionSlug || documentSlug) {
return documentSlug
? documents.get(documentSlug)
: collections.get(collectionSlug!);
}
const share = shares.get(shareId);
return share?.collectionId
? collections.get(share.collectionId)
: share?.documentId
? documents.get(share.documentId)
: undefined;
}
function useActivePage(share?: Share) {
const { collectionSlug, documentSlug } = useParams<PathParams>();
if (!share) {
return;
}
const findInTree = (
node: NavigationNode,
slugToFind: string
): string | undefined => {
if (node.url.endsWith(slugToFind)) {
return node.id;
}
if (node.children) {
for (const child of node.children) {
const foundId = findInTree(child, slugToFind);
if (foundId) {
return foundId;
}
}
}
return;
};
if (!share.tree) {
return share.collectionId
? { type: "collection", id: share.collectionId }
: { type: "document", id: share.documentId };
} else if (documentSlug) {
return { type: "document", id: findInTree(share.tree, documentSlug) };
} else if (collectionSlug) {
return { type: "collection", id: findInTree(share.tree, collectionSlug) };
} else {
if (share.collectionId) {
return { type: "collection", id: share.collectionId };
} else {
return { type: "document", id: share.documentId };
}
}
}
function SharedScene() {
const { t, i18n } = useTranslation();
const { shareId = env.ROOT_SHARE_ID, documentSlug } = useParams<PathParams>();
const location = useLocation<LocationState>();
const { documents, shares, ui } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const [, setPostLoginPath] = usePostLoginPath();
const model = useModel();
const share = shares.get(shareId);
const activePage = useActivePage(share);
const team = share?.team;
const theme = useBuildTheme(team?.customTheme);
const pageTitle =
model instanceof Collection
? model.name
: model instanceof Document
? model.title
: undefined;
const { request, error, loading, loaded } = useRequest(
useCallback(
() =>
Promise.all([
shares.fetch(shareId),
documentSlug ? documents.fetch(documentSlug) : undefined,
]),
[shares, documents, shareId, documentSlug]
)
);
useEffect(() => {
if (!user) {
void changeLanguage(detectLanguage(), i18n);
}
}, [user, i18n]);
useEffect(() => {
client.setShareId(shareId);
return () => client.setShareId(undefined);
}, [shareId]);
useEffect(() => {
if (!activePage || !activePage.id) {
return;
}
if (activePage.type === "document") {
ui.setActiveDocument(activePage.id);
} else {
ui.setActiveCollection(activePage.id);
}
return () => {
if (activePage.type === "document") {
ui.clearActiveDocument();
} else {
ui.setActiveCollection(undefined);
}
};
}, [ui, activePage]);
useEffect(() => {
void request();
}, [request]);
if (loading && !loaded) {
return <Loading location={location} />;
}
if (error) {
if (error instanceof OfflineError) {
return <ErrorOffline />;
}
if (error instanceof AuthorizationError) {
setPostLoginPath(location.pathname);
return (
<Login>
{(config) =>
config?.name && isCloudHosted ? (
<Content>
{t(
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
{
teamName: config.name,
appName: env.APP_NAME,
}
)}
</Content>
) : null
}
</Login>
);
}
return <Error404 />;
}
if (!share) {
return <Error404 />;
}
return (
<>
<Helmet>
<link
rel="canonical"
href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
/>
<link
rel="sitemap"
type="application/xml"
href={Share.sitemapUrl(shareId)}
/>
</Helmet>
<TeamContext.Provider value={team}>
<ThemeProvider theme={theme}>
<DocumentContextProvider>
<Layout title={pageTitle} sidebar={<Sidebar share={share} />}>
{model instanceof Document ? (
<DocumentScene
document={model}
shareId={share.id}
sharedTree={share.tree}
/>
) : model instanceof Collection ? (
<CollectionScene collection={model} shareId={shareId} />
) : null}
</Layout>
<ClickablePadding minHeight="20vh" />
</DocumentContextProvider>
</ThemeProvider>
</TeamContext.Provider>
</>
);
}
const Content = styled(Text)`
color: ${s("textSecondary")};
text-align: center;
margin-top: -8px;
`;
export default observer(SharedScene);
+74 -18
View File
@@ -3,11 +3,11 @@ import filter from "lodash/filter";
import find from "lodash/find";
import isUndefined from "lodash/isUndefined";
import orderBy from "lodash/orderBy";
import { action, computed } from "mobx";
import type { Required } from "utility-types";
import type { JSONObject } from "@shared/types";
import { action, computed, observable } from "mobx";
import type { NavigationNode, PublicTeam } from "@shared/types";
import Document from "~/models/Document";
import Share from "~/models/Share";
import type { Properties } from "~/types";
import type { PartialExcept } from "~/types";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
@@ -20,6 +20,12 @@ export default class SharesStore extends Store<Share> {
RPCAction.Update,
];
@observable
sharedCache: Map<
string,
{ sharedTree: NavigationNode | null; team: PublicTeam } | undefined
> = new Map();
constructor(rootStore: RootStore) {
super(rootStore, Share);
}
@@ -43,26 +49,73 @@ export default class SharesStore extends Store<Share> {
};
@action
async create(params: Required<Properties<Share>, "documentId">) {
const item = this.getByDocumentId(params.documentId);
async create(
params:
| (PartialExcept<Share, "collectionId"> & { type: "collection" })
| (PartialExcept<Share, "documentId"> & { type: "document" })
): Promise<Share> {
const item =
params.type === "collection"
? this.getByCollectionId(params.collectionId)
: this.getByDocumentId(params.documentId);
if (item) {
return item;
}
return super.create(params);
}
@action
async fetch(documentId: string, options: JSONObject = {}): Promise<any> {
const item = this.getByDocumentId(documentId);
if (item && !options.force) {
return item;
async fetch(id: string) {
const share = this.get(id);
const cache = this.sharedCache.get(id);
if (share && cache) {
return share;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
id,
});
invariant(res?.data, "Data should be available");
res.data.shares.map(this.add);
if (res.data.collection) {
this.rootStore.collections.add(res.data.collection);
}
if (res.data.document) {
this.rootStore.documents.add(res.data.document);
}
this.sharedCache.set(id, {
sharedTree: res.data.sharedTree,
team: res.data.team,
});
this.addPolicies(res.policies);
return this.data.get(id)!;
} finally {
this.isFetching = false;
}
}
@action
async fetchOne(params: { documentId: string } | { collectionId: string }) {
const share =
"collectionId" in params
? this.getByCollectionId(params.collectionId)
: this.getByDocumentId(params.documentId);
if (share) {
return share;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
documentId,
});
const res = await client.post(`/${this.apiEndpoint}.info`, params);
if (isUndefined(res)) {
return;
@@ -75,10 +128,13 @@ export default class SharesStore extends Store<Share> {
}
}
getByDocumentParents = (documentId: string): Share | undefined => {
const document = this.rootStore.documents.get(documentId);
if (!document) {
return;
getByDocumentParents = (document: Document): Share | undefined => {
const collectionShare = document.collectionId
? this.getByCollectionId(document.collectionId)
: undefined;
if (collectionShare?.published) {
return collectionShare;
}
const collection = document.collectionId
@@ -89,7 +145,7 @@ export default class SharesStore extends Store<Share> {
}
const parentIds = collection
.pathToDocument(documentId)
.pathToDocument(document.id)
.slice(0, -1)
.map((p) => p.id);
+6 -4
View File
@@ -153,10 +153,12 @@ class ApiClient {
// Handle 401, log out user
if (response.status === 401) {
await stores.auth.logout({
savePath: true,
revokeToken: false,
});
if (!this.shareId) {
await stores.auth.logout({
savePath: true,
revokeToken: false,
});
}
throw new AuthorizationError();
}
+3 -3
View File
@@ -1,13 +1,13 @@
import { sharedDocumentPath } from "./routeHelpers";
import { sharedModelPath } from "./routeHelpers";
describe("#sharedDocumentPath", () => {
test("should return share path for a document", () => {
const shareId = "1c922644-40d8-41fe-98f9-df2b67239d45";
const docPath = "/doc/test-DjDlkBi77t";
expect(sharedDocumentPath(shareId)).toBe(
expect(sharedModelPath(shareId)).toBe(
"/s/1c922644-40d8-41fe-98f9-df2b67239d45"
);
expect(sharedDocumentPath(shareId, docPath)).toBe(
expect(sharedModelPath(shareId, docPath)).toBe(
"/s/1c922644-40d8-41fe-98f9-df2b67239d45/doc/test-DjDlkBi77t"
);
});
+6 -3
View File
@@ -133,18 +133,21 @@ export function searchPath({
return `/search${search}`;
}
export function sharedDocumentPath(shareId: string, docPath?: string) {
export function sharedModelPath(shareId: string, modelPath?: string) {
if (shareId === env.ROOT_SHARE_ID) {
return docPath ? docPath : "/";
return modelPath ? modelPath : "/";
}
return docPath ? `/s/${shareId}${docPath}` : `/s/${shareId}`;
return modelPath ? `/s/${shareId}${modelPath}` : `/s/${shareId}`;
}
export function urlify(path: string): string {
return `${window.location.origin}${path}`;
}
export const matchCollectionSlug =
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
export const matchDocumentSlug =
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
+4 -1
View File
@@ -38,7 +38,10 @@ describe("Slack authentication domain extraction", () => {
const testCases = [
{ email: "user@gmail.com", expectedDomain: "gmail.com" },
{ email: "test@company.com", expectedDomain: "company.com" },
{ email: "admin@subdomain.domain.com", expectedDomain: "subdomain.domain.com" },
{
email: "admin@subdomain.domain.com",
expectedDomain: "subdomain.domain.com",
},
];
testCases.forEach(({ email, expectedDomain }) => {
+17 -7
View File
@@ -72,13 +72,16 @@ export default async function loadDocument({
include: [
{
model: Document.scope("withDrafts"),
required: true,
as: "document",
},
{
model: Collection.scope("withDocumentStructure"),
as: "collection",
},
],
});
if (!share || share.document?.archivedAt) {
if (!share || share.collection?.archivedAt || share.document?.archivedAt) {
throw InvalidRequestError("Document could not be found for shareId");
}
@@ -93,7 +96,7 @@ export default async function loadDocument({
}); // 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) {
} else if (user && share.documentId) {
document = await Document.findByPk(share.documentId, {
userId: user.id,
paranoid: false,
@@ -148,10 +151,17 @@ export default async function loadDocument({
}
}
// 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 active and nested within the shared document
if (share.documentId !== document.id) {
if (share.collectionId) {
// If this is a collection share, we need to ensure that
// the document is within the collection.
const childDocumentIds = share.collection?.getAllDocumentIds() ?? [];
if (!childDocumentIds.includes(document.id)) {
throw AuthorizationError();
}
} else if (share.documentId !== document.id) {
// 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 active and nested within the shared document
if (!share.includeChildDocuments) {
throw AuthorizationError();
}
+343
View File
@@ -0,0 +1,343 @@
import {
buildCollection,
buildDocument,
buildShare,
buildTeam,
buildUser,
} from "@server/test/factories";
import { loadPublicShare, loadShareWithParent } from "./shareLoader";
describe("shareLoader", () => {
describe("collection share", () => {
it("should return share with tree and collection when requested with id", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const childDocument = await buildDocument({
parentDocumentId: document.id,
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const result = await loadPublicShare({
id: share.id,
});
expect(result.share.id).toEqual(share.id);
expect(result.collection?.id).toEqual(collection.id);
expect(result.sharedTree?.id).toEqual(collection.id);
expect(result.sharedTree?.children[0].id).toEqual(document.id);
expect(result.sharedTree?.children[0].children[0].id).toEqual(
childDocument.id
);
expect(result.document).toBeNull();
});
it("should return only share when requested with collectionId", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const result = await loadShareWithParent({
collectionId: collection.id,
user,
});
expect(result.share.id).toEqual(share.id);
expect(result.parentShare).toBeNull();
});
it("should throw error when the requested collection is not part of the share", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const anotherCollection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
await expect(
loadPublicShare({ id: share.id, collectionId: anotherCollection.id })
).rejects.toThrow();
});
});
describe("document share", () => {
it("should return share with tree and document when requested with id", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const childDocument = await buildDocument({
parentDocumentId: document.id,
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
includeChildDocuments: true,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
});
const result = await loadPublicShare({
id: share.id,
});
expect(result.share.id).toEqual(share.id);
expect(result.document?.id).toEqual(document.id);
expect(result.sharedTree?.id).toEqual(document.id);
expect(result.sharedTree?.children.length).toEqual(1);
expect(result.sharedTree?.children[0].id).toEqual(childDocument.id);
expect(result.collection).toBeNull();
});
it("should not return share tree when includeChildDocuments is false", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
await buildDocument({
parentDocumentId: document.id,
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
includeChildDocuments: false,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
});
const result = await loadPublicShare({
id: share.id,
});
expect(result.share.id).toEqual(share.id);
expect(result.document?.id).toEqual(document.id);
expect(result.sharedTree).toBeNull();
expect(result.collection).toBeNull();
});
it("should return share and parentShare when requested with documentId", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const childDocument = await buildDocument({
parentDocumentId: document.id,
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const [parentShare, share] = await Promise.all([
buildShare({
includeChildDocuments: true,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
}),
buildShare({
includeChildDocuments: false,
userId: user.id,
teamId: user.teamId,
documentId: childDocument.id,
}),
]);
const result = await loadShareWithParent({
documentId: childDocument.id,
user,
});
expect(result.share.id).toEqual(share.id);
expect(result.parentShare?.id).toEqual(parentShare.id);
});
it("should throw error when the requested document is not part of the share (includeChildDocuments = true)", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const anotherDocument = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
includeChildDocuments: true,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
});
await expect(
loadPublicShare({ id: share.id, documentId: anotherDocument.id })
).rejects.toThrow();
});
it("should throw error when the requested document is not part of the share (includeChildDocuments = false)", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const anotherDocument = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
includeChildDocuments: false,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
});
await expect(
loadPublicShare({ id: share.id, documentId: anotherDocument.id })
).rejects.toThrow();
});
it("should throw error when the child document is requested for a share with includeChildDocuments = false", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
const childDocument = await buildDocument({
parentDocumentId: document.id,
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
includeChildDocuments: false,
userId: user.id,
teamId: user.teamId,
documentId: document.id,
});
await expect(
loadPublicShare({ id: share.id, documentId: childDocument.id })
).rejects.toThrow();
});
});
describe("inactive share when requested with id", () => {
it("should throw error when share is not published", async () => {
const share = await buildShare({
published: false,
});
await expect(loadPublicShare({ id: share.id })).rejects.toThrow();
});
it("should throw error when team has disabled sharing", async () => {
const team = await buildTeam({
sharing: false,
});
const share = await buildShare({
teamId: team.id,
});
await expect(loadPublicShare({ id: share.id })).rejects.toThrow();
});
it("should throw error when collection has disabled sharing", async () => {
const collection = await buildCollection({
sharing: false,
});
const share = await buildShare({
collectionId: collection.id,
teamId: collection.teamId,
});
await expect(loadPublicShare({ id: share.id })).rejects.toThrow();
});
it("should throw error when collection is archived", async () => {
const collection = await buildCollection({
archivedAt: new Date(),
});
const share = await buildShare({
collectionId: collection.id,
teamId: collection.teamId,
});
await expect(loadPublicShare({ id: share.id })).rejects.toThrow();
});
it("should throw error when document is archived", async () => {
const document = await buildDocument({
archivedAt: new Date(),
});
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
});
await expect(loadPublicShare({ id: share.id })).rejects.toThrow();
});
});
});
+234
View File
@@ -0,0 +1,234 @@
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { NavigationNode } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { AuthorizationError, NotFoundError } from "@server/errors";
import { Collection, Document, Share, User } from "@server/models";
import { authorize, can } from "@server/policies";
type LoadPublicShareProps = {
id: string;
collectionId?: string;
documentId?: string;
teamId?: string;
};
export async function loadPublicShare({
id,
collectionId,
documentId,
teamId,
}: LoadPublicShareProps) {
const urlId =
!isUUID(id) && UrlHelper.SHARE_URL_SLUG_REGEX.test(id) ? id : undefined;
if (urlId && !teamId) {
throw new Error("teamId required for fetching share using urlId");
}
const where: WhereOptions<Share> = {
revokedAt: {
[Op.is]: null,
},
published: true,
};
if (urlId) {
where.urlId = id;
where.teamId = teamId;
} else {
where.id = id;
}
const share = await Share.findOne({
where,
include: [
{
model: Document.scope("withDrafts"),
as: "document",
include: [
{
model: Collection.scope("withDocumentStructure"),
as: "collection",
required: false,
},
],
},
{
model: Collection.scope("withDocumentStructure"),
as: "collection",
},
],
});
if (
!share ||
!!share.team.suspendedAt ||
!!share.collection?.archivedAt ||
!!share.document?.archivedAt
) {
throw NotFoundError();
}
const isDraftWithoutCollection =
!!share.document?.isDraft && !share.document.collectionId;
const associatedCollection = share.collection ?? share.document?.collection;
if (
!share.team.sharing ||
(!isDraftWithoutCollection && !associatedCollection?.sharing)
) {
throw AuthorizationError();
}
let sharedTree: NavigationNode | null = null;
let document: Document | null = null;
if (share.collection) {
sharedTree = associatedCollection?.toNavigationNode() ?? null;
} else if (share.document && share.includeChildDocuments) {
sharedTree =
associatedCollection?.getDocumentTree(share.document.id) ?? null;
}
if (collectionId && collectionId !== share.collectionId) {
throw AuthorizationError();
}
if (documentId && documentId !== share.documentId) {
document = await Document.findByPk(documentId, {
rejectOnEmpty: true,
});
let isDocumentAccessible = share.documentId === document.id;
if (share.includeChildDocuments) {
const allIdsInSharedTree = getAllIdsInSharedTree(sharedTree);
isDocumentAccessible = allIdsInSharedTree.includes(document.id);
}
if (!isDocumentAccessible) {
throw AuthorizationError();
}
} else {
document = share.document;
}
return {
share,
sharedTree,
collection: share.collection,
document,
};
}
type LoadShareWithParentProps = {
collectionId?: string;
documentId?: string;
user: User;
};
export async function loadShareWithParent({
collectionId,
documentId,
user,
}: LoadShareWithParentProps) {
const where: WhereOptions<Share> = {
revokedAt: {
[Op.is]: null,
},
teamId: user.teamId,
};
if (collectionId) {
where.collectionId = collectionId;
} else if (documentId) {
where.documentId = documentId;
}
const share = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findOne({ where });
if (!share) {
throw NotFoundError();
}
authorize(user, "read", share);
if (collectionId) {
authorize(user, "read", share.collection);
}
let parentShare: Share | null = null;
// Load the parent shares and return one (needed for share toggle in UI).
// Parent share is needed for documents only since collections don't have parents.
if (documentId) {
authorize(user, "read", share.document);
const docCollectionId = share.document.collectionId;
if (!docCollectionId) {
throw NotFoundError("Collection not found for the shared document");
}
const docCollection = await Collection.findByPk(docCollectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
});
const collectionShare = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findOne({
where: {
revokedAt: {
[Op.is]: null,
},
published: true,
teamId: user.teamId,
collectionId: docCollectionId,
},
});
// prefer collection share if it exists and user has read access.
if (collectionShare && can(user, "read", collectionShare)) {
parentShare = collectionShare;
} else {
const parentDocIds = docCollection.getDocumentParents(documentId);
const allParentShares = parentDocIds
? await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findAll({
where: {
revokedAt: {
[Op.is]: null,
},
published: true,
teamId: user.teamId,
includeChildDocuments: true,
documentId: parentDocIds,
},
})
: null;
parentShare = allParentShares?.find((s) => can(user, "read", s)) ?? null;
}
}
return { share, parentShare };
}
function getAllIdsInSharedTree(sharedTree: NavigationNode | null): string[] {
if (!sharedTree) {
return [];
}
const ids = [sharedTree.id];
for (const child of sharedTree.children) {
ids.push(...getAllIdsInSharedTree(child));
}
return ids;
}
@@ -0,0 +1,37 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"shares",
"collectionId",
{
type: Sequelize.UUID,
allowNull: true,
references: {
model: "collections",
},
},
{ transaction }
);
await queryInterface.sequelize.query(
'ALTER TABLE shares ALTER COLUMN "documentId" DROP NOT NULL;',
{ transaction }
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("shares", "collectionId", {
transaction,
});
await queryInterface.sequelize.query(
'ALTER TABLE shares ALTER COLUMN "documentId" SET NOT NULL;',
{ transaction }
);
});
},
};
+39
View File
@@ -2,6 +2,7 @@
import fractionalIndex from "fractional-index";
import find from "lodash/find";
import findIndex from "lodash/findIndex";
import isNil from "lodash/isNil";
import remove from "lodash/remove";
import uniq from "lodash/uniq";
import {
@@ -958,6 +959,44 @@ class Collection extends ParanoidModel<
return this;
};
/**
* Get all of the document ids that are in this collection by
* recursively iterating through `documentStructure`.
*
* @returns list of document ids
*/
getAllDocumentIds = (): string[] => {
if (!this.documentStructure) {
return [];
}
const allDocumentIds: string[] = [];
const loopChildren = (node: NavigationNode) => {
allDocumentIds.push(node.id);
(node.children ?? []).forEach((childNode) => {
loopChildren(childNode);
});
};
this.documentStructure.forEach(loopChildren);
return allDocumentIds;
};
/**
* Returns a JSON representation of this collection suitable for use in the frontend navigation.
*
* @returns NavigationNode
*/
toNavigationNode = (): NavigationNode => ({
id: this.id,
title: this.name,
url: this.path,
icon: isNil(this.icon) ? undefined : this.icon,
color: isNil(this.color) ? undefined : this.color,
children: sortNavigationNodes(this.documentStructure ?? [], this.sort),
});
}
export default Collection;
+36 -2
View File
@@ -36,6 +36,10 @@ import Length from "./validators/Length";
association: "user",
paranoid: false,
},
{
association: "collection",
required: false,
},
{
association: "document",
required: false,
@@ -48,6 +52,21 @@ import Length from "./validators/Length";
@Scopes(() => ({
withCollectionPermissions: (userId: string) => ({
include: [
{
attributes: [
"id",
"name",
"permission",
"sharing",
"urlId",
"teamId",
"deletedAt",
],
model: Collection.scope({
method: ["withMembership", userId],
}),
as: "collection",
},
{
model: Document.scope([
"withDrafts",
@@ -59,7 +78,15 @@ import Length from "./validators/Length";
as: "document",
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
attributes: [
"id",
"name",
"permission",
"urlId",
"sharing",
"teamId",
"deletedAt",
],
model: Collection.scope({
method: ["withMembership", userId],
}),
@@ -186,12 +213,19 @@ class Share extends IdModel<
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Collection, "collectionId")
collection: Collection | null;
@ForeignKey(() => Collection)
@Column(DataType.UUID)
collectionId: string | null;
@BelongsTo(() => Document, "documentId")
document: Document | null;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
documentId: string | null;
revoke(ctx: APIContext) {
const { user } = ctx.state.auth;
+17 -8
View File
@@ -191,14 +191,22 @@ export class DocumentHelper {
/**
* Returns the document as plain HTML. This is a lossy conversion and should only be used for export.
*
* @param document The document or revision to convert
* @param model The document or revision or collection to convert
* @param options Options for the HTML output
* @returns The document title and content as a HTML string
*/
static async toHTML(document: Document | Revision, options?: HTMLOptions) {
const node = DocumentHelper.toProsemirror(document);
static async toHTML(
model: Document | Revision | Collection,
options?: HTMLOptions
) {
const node = DocumentHelper.toProsemirror(model);
let output = ProsemirrorHelper.toHTML(node, {
title: options?.includeTitle !== false ? document.title : undefined,
title:
options?.includeTitle !== false
? model instanceof Collection
? model.name
: model.title
: undefined,
includeStyles: options?.includeStyles,
includeMermaid: options?.includeMermaid,
includeHead: options?.includeHead,
@@ -207,15 +215,16 @@ export class DocumentHelper {
});
addTags({
documentId: document.id,
collectionId: model instanceof Collection ? model.id : undefined,
documentId: !(model instanceof Collection) ? model.id : undefined,
options,
});
if (options?.signedUrls) {
const teamId =
document instanceof Document
? document.teamId
: (await document.$get("document"))?.teamId;
model instanceof Collection || model instanceof Document
? model.teamId
: (await model.$get("document"))?.teamId;
if (!teamId) {
return output;
+2 -2
View File
@@ -283,8 +283,8 @@ export class ProsemirrorHelper {
}
function replaceUrl(url: string) {
// Only replace if the URL starts with /doc/ (not already in a share path)
if (url.startsWith("/doc/")) {
// Only replace if the URL starts with /doc/ (or) /collection/ (not already in a share path)
if (url.startsWith("/doc/") || url.startsWith("/collection/")) {
return `${basePath}${url}`;
}
return url;
+24 -9
View File
@@ -219,18 +219,33 @@ export default class SearchHelper {
statusFilter: [...(options.statusFilter || []), StatusFilter.Published],
});
if (options.share?.includeChildDocuments) {
const sharedDocument = await options.share.$get("document");
invariant(sharedDocument, "Cannot find document for share");
if (options.share) {
let documentIds: string[] | undefined;
const childDocumentIds = await sharedDocument.findAllChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
});
if (options.share.collectionId) {
const sharedCollection =
options.share.collection ??
(await options.share.$get("collection", { scope: "unscoped" }));
invariant(sharedCollection, "Cannot find collection for share");
documentIds = sharedCollection.getAllDocumentIds();
} else if (
options.share.documentId &&
options.share.includeChildDocuments
) {
const sharedDocument = await options.share.$get("document");
invariant(sharedDocument, "Cannot find document for share");
const childDocumentIds = await sharedDocument.findAllChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
});
documentIds = [sharedDocument.id, ...childDocumentIds];
}
where[Op.and].push({
id: [sharedDocument.id, ...childDocumentIds],
id: documentIds,
});
}
+4 -1
View File
@@ -32,7 +32,10 @@ allow(User, "update", Share, (actor, share) =>
isTeamModel(actor, share),
!actor.isGuest,
!actor.isViewer,
can(actor, "share", share?.document)
or(
can(actor, "share", share?.collection),
can(actor, "share", share?.document)
)
)
);
+37 -4
View File
@@ -1,20 +1,42 @@
import { Hour } from "@shared/utils/time";
import Collection from "@server/models/Collection";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { APIContext } from "@server/types";
import presentUser from "./user";
type Options = {
/** Whether to render the collection's public fields. */
isPublic?: boolean;
/** The root share ID when presenting a shared collection. */
shareId?: string;
/** Whether to include the updatedAt timestamp. */
includeUpdatedAt?: boolean;
};
export default async function presentCollection(
ctx: APIContext | undefined,
collection: Collection
collection: Collection,
options: Options = {}
) {
const asData = !ctx || Number(ctx?.headers["x-api-version"] ?? 0) >= 3;
return {
const res: Record<string, any> = {
id: collection.id,
url: collection.url,
urlId: collection.urlId,
name: collection.name,
data: asData ? await DocumentHelper.toJSON(collection) : undefined,
data: asData
? await DocumentHelper.toJSON(
collection,
options.isPublic
? {
signedUrls: Hour.seconds,
teamId: collection.teamId,
internalUrlBase: `/s/${options.shareId}`,
}
: undefined
)
: undefined,
description: asData ? undefined : collection.description,
sort: collection.sort,
icon: collection.icon,
@@ -27,6 +49,17 @@ export default async function presentCollection(
updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt,
archivedAt: collection.archivedAt,
archivedBy: collection.archivedBy && presentUser(collection.archivedBy),
archivedBy: undefined,
};
if (options.isPublic && !options.includeUpdatedAt) {
delete res.updatedAt;
}
if (!options.isPublic) {
res.archivedBy =
collection.archivedBy && presentUser(collection.archivedBy);
}
return res;
}
+3
View File
@@ -4,6 +4,9 @@ import { presentUser } from ".";
export default function presentShare(share: Share, isAdmin = false) {
const data = {
id: share.id,
sourceTitle: share.collection?.name ?? share.document?.title,
sourcePath: share.collection?.path ?? share.document?.path,
collectionId: share.collectionId,
documentId: share.documentId,
documentTitle: share.document?.title,
documentUrl: share.document?.url,
+2 -2
View File
@@ -62,10 +62,10 @@ export default class ExportJSONTask extends ExportTask {
) {
const output: CollectionJSONExport = {
collection: {
...omit(await presentCollection(undefined, collection), [
...(omit(await presentCollection(undefined, collection), [
"url",
"description",
]),
]) as CollectionJSONExport["collection"]),
documentStructure: collection.documentStructure,
},
documents: {},
+9 -1
View File
@@ -1,10 +1,12 @@
import isUndefined from "lodash/isUndefined";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import {
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
} from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { Collection } from "@server/models";
import { zodIconType, zodIdType } from "@server/utils/zod";
import { ValidateColor, ValidateIndex } from "@server/validation";
@@ -50,7 +52,13 @@ export const CollectionsCreateSchema = BaseSchema.extend({
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
export const CollectionsInfoSchema = BaseSchema.extend({
body: BaseIdSchema,
body: BaseIdSchema.extend({
/** Share Id, if available */
shareId: z
.string()
.refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
.optional(),
}),
});
export type CollectionsInfoReq = z.infer<typeof CollectionsInfoSchema>;
+32 -10
View File
@@ -77,6 +77,7 @@ import { navigationNodeToSitemap } from "@server/utils/sitemap";
import { assertPresent } from "@server/validation";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
import { loadPublicShare } from "@server/commands/shareLoader";
const router = new Router();
@@ -597,8 +598,11 @@ router.post(
)
: undefined,
sharedTree:
share && share.includeChildDocuments && collection
? collection.getDocumentTree(share.documentId)
share &&
share.documentId &&
share.includeChildDocuments &&
collection
? collection?.getDocumentTree(share.documentId)
: null,
}
: serializedDocument;
@@ -705,7 +709,12 @@ router.get(
});
let tree;
if (share && share.includeChildDocuments && share.allowIndexing) {
if (
share &&
share.documentId &&
share.includeChildDocuments &&
share.allowIndexing
) {
tree = collection?.getDocumentTree(share.documentId);
}
@@ -1019,16 +1028,29 @@ router.post(
if (shareId) {
const teamFromCtx = await getTeamFromContext(ctx);
const { document, ...loaded } = await documentLoader({
const result = await loadPublicShare({
id: shareId,
teamId: teamFromCtx?.id,
shareId,
user,
});
share = loaded.share;
isPublic = cannot(user, "read", document);
share = result.share;
let { collection, document } = result; // One of collection or document should be available
if (!share?.includeChildDocuments) {
// reload with membership scope if user is authenticated
if (user) {
collection = collection
? await Collection.findByPk(collection.id, { userId: user.id })
: null;
document = document
? await Document.findByPk(document.id, { userId: user.id })
: null;
}
isPublic = collection
? cannot(user, "read", collection)
: cannot(user, "read", document);
if (share.documentId && !share?.includeChildDocuments) {
throw InvalidRequestError("Child documents cannot be searched");
}
@@ -1038,7 +1060,7 @@ router.post(
response = await SearchHelper.searchForTeam(team, {
query,
collectionId: document.collectionId,
collectionId: collection?.id || document?.collectionId,
share,
dateFilter,
statusFilter,
@@ -9,15 +9,6 @@ exports[`#shares.create should require authentication 1`] = `
}
`;
exports[`#shares.info should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#shares.list should require authentication 1`] = `
{
"error": "authentication_required",
+40 -31
View File
@@ -1,28 +1,28 @@
import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { Share } from "@server/models";
import { zodIdType } from "@server/utils/zod";
import { BaseSchema } from "../schema";
export const SharesInfoSchema = BaseSchema.extend({
body: z
.object({
id: z.string().uuid().optional(),
documentId: z
.string()
.optional()
.refine(
(val) =>
val ? isUUID(val) || UrlHelper.SLUG_URL_REGEX.test(val) : true,
{
message: "must be uuid or url slug",
}
),
collectionId: zodIdType().optional(),
documentId: zodIdType().optional(),
})
.refine((body) => !(isEmpty(body.id) && isEmpty(body.documentId)), {
message: "id or documentId is required",
}),
.refine(
(body) =>
!(
isEmpty(body.id) &&
isEmpty(body.collectionId) &&
isEmpty(body.documentId)
),
{
message: "one of id, collectionId, or documentId is required",
}
),
});
export type SharesInfoReq = z.infer<typeof SharesInfoSchema>;
@@ -66,23 +66,24 @@ export const SharesUpdateSchema = BaseSchema.extend({
export type SharesUpdateReq = z.infer<typeof SharesUpdateSchema>;
export const SharesCreateSchema = BaseSchema.extend({
body: z.object({
documentId: z
.string()
.refine((val) => isUUID(val) || UrlHelper.SLUG_URL_REGEX.test(val), {
message: "must be uuid or url slug",
}),
published: z.boolean().default(false),
allowIndexing: z.boolean().optional(),
showLastUpdated: z.boolean().optional(),
urlId: z
.string()
.regex(UrlHelper.SHARE_URL_SLUG_REGEX, {
message: "must contain only alphanumeric and dashes",
})
.optional(),
includeChildDocuments: z.boolean().default(false),
}),
body: z
.object({
collectionId: zodIdType().optional(),
documentId: zodIdType().optional(),
published: z.boolean().default(false),
allowIndexing: z.boolean().optional(),
showLastUpdated: z.boolean().optional(),
urlId: z
.string()
.regex(UrlHelper.SHARE_URL_SLUG_REGEX, {
message: "must contain only alphanumeric and dashes",
})
.optional(),
includeChildDocuments: z.boolean().default(false),
})
.refine((obj) => !(isEmpty(obj.collectionId) && isEmpty(obj.documentId)), {
message: "one of collectionId or documentId is required",
}),
});
export type SharesCreateReq = z.infer<typeof SharesCreateSchema>;
@@ -94,3 +95,11 @@ export const SharesRevokeSchema = BaseSchema.extend({
});
export type SharesRevokeReq = z.infer<typeof SharesRevokeSchema>;
export const SharesSitemapSchema = BaseSchema.extend({
query: z.object({
id: z.string(),
}),
});
export type SharesSitemapReq = z.infer<typeof SharesSitemapSchema>;
+58 -54
View File
@@ -236,7 +236,7 @@ describe("#shares.list", () => {
});
describe("#shares.create", () => {
it("should fail with status 400 bad request when documentId is missing", async () => {
it("should fail with status 400 bad request when both documentId and collectionId are missing", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.create", {
body: {
@@ -245,7 +245,9 @@ describe("#shares.create", () => {
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: Required");
expect(body.message).toEqual(
"body: one of collectionId or documentId is required"
);
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
@@ -253,12 +255,30 @@ describe("#shares.create", () => {
const res = await server.post("/api/shares.create", {
body: {
token: user.getJwtToken(),
documentId: "id",
documentId: "foo",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: must be uuid or url slug");
expect(body.message).toEqual("documentId: Invalid");
});
it("should allow creating a share record for collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/shares.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.published).toBe(false);
expect(body.data.sourceTitle).toBe(collection.name);
});
it("should allow creating a share record for document", async () => {
@@ -532,7 +552,7 @@ describe("#shares.create", () => {
});
describe("#shares.info", () => {
it("should fail with status 400 bad request when id and documentId both are missing", async () => {
it("should fail with status 400 bad request when id, collectionId and documentId are missing", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.info", {
body: {
@@ -541,7 +561,9 @@ describe("#shares.info", () => {
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("body: id or documentId is required");
expect(body.message).toEqual(
"body: one of id, collectionId, or documentId is required"
);
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
@@ -549,12 +571,12 @@ describe("#shares.info", () => {
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: "id",
documentId: "foo",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: must be uuid or url slug");
expect(body.message).toEqual("documentId: Invalid");
});
it("should not find share by documentId in private collection", async () => {
@@ -585,46 +607,6 @@ describe("#shares.info", () => {
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.info", {
body: {
id: share.id,
},
});
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should require authorization", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const document = await buildDocument({ teamId: team.id });
const user = await buildUser();
const share = await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
id: share.id,
},
});
expect(res.status).toEqual(403);
});
it("should succeed with status 200 ok", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -685,6 +667,7 @@ describe("#shares.info", () => {
teamId: user.teamId,
});
const childDocument = await buildDocument({
userId: user.id,
teamId: document.teamId,
parentDocumentId: document.id,
collectionId: collection.id,
@@ -695,6 +678,11 @@ describe("#shares.info", () => {
userId: user.id,
includeChildDocuments: true,
});
const childShare = await buildShare({
documentId: childDocument.id,
teamId: childDocument.teamId,
userId: user.id,
});
await collection.reload();
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
@@ -705,13 +693,17 @@ describe("#shares.info", () => {
});
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.length).toBe(2);
expect(body.data.shares[0].id).toBe(childShare.id);
expect(body.data.shares[0].documentId).toBe(childDocument.id);
expect(body.data.shares[0].published).toBe(true);
expect(body.data.shares[0].includeChildDocuments).toBe(true);
expect(body.policies.length).toBe(1);
expect(body.data.shares[1].id).toBe(share.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);
expect(body.policies.length).toBe(2);
expect(body.policies[0].abilities.update).toBeTruthy();
expect(body.policies[1].abilities.update).toBeTruthy();
});
it("should not return share for parent document with includeChildDocuments=false", async () => {
const team = await buildTeam();
@@ -736,6 +728,11 @@ describe("#shares.info", () => {
userId: user.id,
includeChildDocuments: false,
});
const share = await buildShare({
documentId: childDocument.id,
teamId: childDocument.teamId,
userId: user.id,
});
await collection.addDocumentToStructure(childDocument, 0);
const res = await server.post("/api/shares.info", {
body: {
@@ -743,7 +740,14 @@ describe("#shares.info", () => {
documentId: childDocument.id,
},
});
expect(res.status).toEqual(204);
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(childDocument.id);
expect(body.data.shares[0].published).toBe(true);
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.update).toBeTruthy();
});
it("should return shares for parent document and current document", async () => {
const user = await buildUser();
+154 -88
View File
@@ -1,87 +1,108 @@
import Router from "koa-router";
import isUndefined from "lodash/isUndefined";
import { FindOptions, Op, WhereOptions } from "sequelize";
import { FindOptions, Op, WhereAttributeHash, WhereOptions } from "sequelize";
import { TeamPreference } from "@shared/types";
import { NotFoundError } from "@server/errors";
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 { Document, User, Share, Team, Collection } from "@server/models";
import { authorize } from "@server/policies";
import { presentShare, presentPolicies } from "@server/presenters";
import { authorize, cannot } from "@server/policies";
import {
presentShare,
presentPolicies,
presentPublicTeam,
presentCollection,
presentDocument,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { getTeamFromContext } from "@server/utils/passport";
import { navigationNodeToSitemap } from "@server/utils/sitemap";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
import {
loadPublicShare,
loadShareWithParent,
} from "@server/commands/shareLoader";
const router = new Router();
router.post(
"shares.info",
auth(),
auth({ optional: true }),
validate(T.SharesInfoSchema),
async (ctx: APIContext<T.SharesInfoReq>) => {
const { id, documentId } = ctx.input.body;
const { id, collectionId, documentId } = ctx.input.body;
const { user } = ctx.state.auth;
const shares = [];
const share = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findOne({
where: id
? {
id,
revokedAt: {
[Op.is]: null,
},
}
: {
documentId,
teamId: user.teamId,
revokedAt: {
[Op.is]: null,
},
},
const teamFromCtx = await getTeamFromContext(ctx);
// only public link loads will send "id".
if (id) {
let { share, sharedTree, collection, document } = await loadPublicShare({
id,
collectionId,
documentId,
teamId: teamFromCtx?.id,
});
// reload with membership scope if user is authenticated
if (user) {
collection = collection
? await Collection.findByPk(collection.id, { userId: user.id })
: null;
document = document
? await Document.findByPk(document.id, { userId: user.id })
: null;
}
const team = teamFromCtx?.id === share.teamId ? teamFromCtx : share.team;
const [serializedCollection, serializedDocument, serializedTeam] =
await Promise.all([
collection
? await presentCollection(ctx, collection, {
isPublic: cannot(user, "read", collection),
shareId: share.id,
includeUpdatedAt: share.showLastUpdated,
})
: null,
document
? await presentDocument(ctx, document, {
isPublic: cannot(user, "read", document),
shareId: share.id,
includeUpdatedAt: share.showLastUpdated,
})
: null,
presentPublicTeam(
team,
!!team.getPreference(TeamPreference.PublicBranding)
),
]);
ctx.body = {
data: {
shares: [presentShare(share, user?.isAdmin ?? false)],
sharedTree: sharedTree,
team: serializedTeam,
collection: serializedCollection,
document: serializedDocument,
},
policies: presentPolicies(user, [share]),
};
return;
}
// load share with parent for displaying in the share popovers.
const { share, parentShare } = await loadShareWithParent({
collectionId,
documentId,
user,
});
// We return 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.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
const collection = document.collectionId
? await Collection.findByPk(document.collectionId, {
userId: user.id,
includeDocumentStructure: true,
})
: undefined;
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findOne({
where: {
documentId: parentIds,
teamId: user.teamId,
revokedAt: {
[Op.is]: null,
},
includeChildDocuments: true,
published: true,
},
})
: undefined;
if (parentShare && parentShare.document) {
authorize(user, "read", parentShare);
shares.push(parentShare);
}
}
const shares = [share, parentShare].filter(Boolean) as Share[];
if (!shares.length) {
ctx.response.status = 204;
@@ -90,7 +111,7 @@ router.post(
ctx.body = {
data: {
shares: shares.map((share) => presentShare(share, user.isAdmin)),
shares: shares.map((s) => presentShare(s, user.isAdmin ?? false)),
},
policies: presentPolicies(user, shares),
};
@@ -108,7 +129,24 @@ router.post(
authorize(user, "listShares", user.team);
const collectionIds = await user.collectionIds();
const where: WhereOptions<Share> = {
const collectionWhere: WhereAttributeHash<Share> = {
"$collection.id$": collectionIds,
"$collection.teamId$": user.teamId,
};
const documentWhere: WhereAttributeHash<Share> = {
"$document.teamId$": user.teamId,
"$document.collectionId$": collectionIds,
};
if (query) {
collectionWhere["$collection.name$"] = { [Op.iLike]: `%${query}%` };
documentWhere["$document.title$"] = {
[Op.iLike]: `%${query}%`,
};
}
const shareWhere: WhereOptions<Share> = {
teamId: user.teamId,
userId: user.id,
published: true,
@@ -117,30 +155,28 @@ router.post(
},
};
const documentWhere: WhereOptions<Document> = {
teamId: user.teamId,
collectionId: collectionIds,
};
if (query) {
documentWhere.title = {
[Op.iLike]: `%${query}%`,
};
}
if (user.isAdmin) {
delete where.userId;
delete shareWhere.userId;
}
const options: FindOptions = {
where,
where: {
...shareWhere,
[Op.or]: [collectionWhere, documentWhere],
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
required: false,
},
{
model: Document,
required: true,
required: false,
paranoid: true,
as: "document",
where: documentWhere,
include: [
{
model: Collection.scope({
@@ -161,10 +197,11 @@ router.post(
as: "team",
},
],
subQuery: false,
};
const [shares, total] = await Promise.all([
Share.findAll({
Share.unscoped().findAll({
...options,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
@@ -188,6 +225,7 @@ router.post(
transaction(),
async (ctx: APIContext<T.SharesCreateReq>) => {
const {
collectionId,
documentId,
published,
urlId,
@@ -198,21 +236,29 @@ router.post(
const { user } = ctx.state.auth;
authorize(user, "createShare", user.team);
const document = await Document.findByPk(documentId, {
userId: user.id,
});
const collection = collectionId
? await Collection.findByPk(collectionId, {
userId: user.id,
})
: null;
const document = documentId
? await Document.findByPk(documentId, {
userId: user.id,
})
: null;
// user could be creating the share link to share with team members
authorize(user, "read", document);
authorize(user, "read", collectionId ? collection : document);
if (published) {
authorize(user, "share", user.team);
authorize(user, "share", document);
authorize(user, "share", collectionId ? collection : document);
}
const [share] = await Share.findOrCreateWithCtx(ctx, {
where: {
documentId,
collectionId: collectionId ?? null,
documentId: documentId ?? null,
teamId: user.teamId,
revokedAt: null,
},
@@ -228,6 +274,7 @@ router.post(
share.team = user.team;
share.user = user;
share.collection = collection;
share.document = document;
ctx.body = {
@@ -318,4 +365,23 @@ router.post(
}
);
router.get(
"shares.sitemap",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
validate(T.SharesSitemapSchema),
async (ctx: APIContext<T.SharesSitemapReq>) => {
const { id } = ctx.input.query;
const { share, sharedTree } = await loadPublicShare({ id });
const baseUrl = `${process.env.URL}/s/${id}`;
ctx.set("Content-Type", "application/xml");
ctx.body = navigationNodeToSitemap(
share.allowIndexing ? sharedTree : undefined,
baseUrl
);
}
);
export default router;
+43 -21
View File
@@ -7,7 +7,6 @@ import { Sequelize } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { IntegrationType, TeamPreference } from "@shared/types";
import { unicodeCLDRtoISO639 } from "@shared/utils/date";
import documentLoader from "@server/commands/documentLoader";
import env from "@server/env";
import { Integration } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -15,6 +14,7 @@ import presentEnv from "@server/presenters/env";
import { getTeamFromContext } from "@server/utils/passport";
import prefetchTags from "@server/utils/prefetchTags";
import readManifestFile from "@server/utils/readManifestFile";
import { loadPublicShare } from "@server/commands/shareLoader";
const readFile = util.promisify(fs.readFile);
const entry = "app/index.tsx";
@@ -143,22 +143,27 @@ export const renderApp = async (
export const renderShare = async (ctx: Context, next: Next) => {
const rootShareId = ctx.state?.rootShare?.id;
const shareId = rootShareId ?? ctx.params.shareId;
const collectionSlug = ctx.params.collectionSlug;
const documentSlug = ctx.params.documentSlug;
// Find the share record if publicly published so that the document title
// can be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliability
let share, document, team;
let share, collection, document, team;
let analytics: Integration<IntegrationType.Analytics>[] = [];
try {
team = await getTeamFromContext(ctx);
const result = await documentLoader({
id: documentSlug,
shareId,
const result = await loadPublicShare({
id: shareId,
collectionId: collectionSlug,
documentId: documentSlug,
teamId: team?.id,
});
share = result.share;
collection = result.collection;
document = result.document;
if (isUUID(shareId) && share?.urlId) {
// Redirect temporarily because the url slug
// can be modified by the user at any time
@@ -166,11 +171,10 @@ export const renderShare = async (ctx: Context, next: Next) => {
ctx.status = 307;
return;
}
document = result.document;
analytics = await Integration.findAll({
where: {
teamId: document.teamId,
teamId: share.teamId,
type: IntegrationType.Analytics,
},
});
@@ -197,30 +201,48 @@ export const renderShare = async (ctx: Context, next: Next) => {
const publicBranding =
team?.getPreference(TeamPreference.PublicBranding) ?? false;
// Inject share information in SSR HTML
return renderApp(ctx, next, {
title:
document?.title || (publicBranding && team?.name ? team.name : undefined),
description:
document?.getSummary() ||
(publicBranding && team?.description ? team.description : undefined),
content: document
? await DocumentHelper.toHTML(document, {
const title = document
? document.title
: collection
? collection.name
: publicBranding && team?.name
? team.name
: undefined;
const content =
document || collection
? await DocumentHelper.toHTML(document || collection!, {
includeStyles: false,
includeHead: false,
includeTitle: true,
signedUrls: true,
})
: undefined,
: undefined;
const canonicalUrl =
share && share.canonicalUrl !== ctx.request.origin + ctx.request.url
? `${share.canonicalUrl}${
documentSlug && document
? document.path
: collectionSlug && collection
? collection.path
: ""
}`
: undefined;
// Inject share information in SSR HTML
return renderApp(ctx, next, {
title,
description:
document?.getSummary() ||
(publicBranding && team?.description ? team.description : undefined),
content,
shortcutIcon:
publicBranding && team?.avatarUrl ? team.avatarUrl : undefined,
analytics,
isShare: true,
rootShareId,
canonical:
share && share.canonicalUrl !== ctx.request.origin + ctx.request.url
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
: undefined,
canonical: canonicalUrl,
allowIndexing: share?.allowIndexing,
});
};
+1 -1
View File
@@ -77,7 +77,7 @@ export async function buildShare(overrides: Partial<Share> = {}) {
overrides.userId = user.id;
}
if (!overrides.documentId) {
if (!overrides.documentId && !overrides.collectionId) {
const document = await buildDocument({
createdById: overrides.userId,
teamId: overrides.teamId,
+18 -14
View File
@@ -326,12 +326,25 @@
"Everyone in the workspace": "Everyone in the workspace",
"{{ count }} member": "{{ count }} member",
"{{ count }} member_plural": "{{ count }} members",
"Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed",
"Sorry, this link has already been used": "Sorry, this link has already been used",
"Public link copied to clipboard": "Public link copied to clipboard",
"Web": "Web",
"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",
"Show last modified": "Show last modified",
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
"All documents in this collection will be shared on the web, including any new documents added later": "All documents in this collection will be shared on the web, including any new documents added later",
"Invite": "Invite",
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
"{{ count }} people added to the collection": "{{ count }} people added to the collection",
"{{ count }} people added to the collection_plural": "{{ count }} people added to the collection",
"{{ count }} people and {{ count2 }} groups added to the collection": "{{ count }} people and {{ count2 }} groups added to the collection",
"{{ count }} people and {{ count2 }} groups added to the collection_plural": "{{ count }} people and {{ count2 }} groups added to the collection",
"Switch to dark": "Switch to dark",
"Switch to light": "Switch to light",
"Add": "Add",
"Add or invite": "Add or invite",
"Viewer": "Viewer",
@@ -356,17 +369,8 @@
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"Leave": "Leave",
"Only lowercase letters, digits and dashes allowed": "Only lowercase letters, digits and dashes allowed",
"Sorry, this link has already been used": "Sorry, this link has already been used",
"Public link copied to clipboard": "Public link copied to clipboard",
"Web": "Web",
"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",
"Show last modified": "Show last modified",
"Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page",
"Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}</2>, is shared",
"Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}</2>, is shared",
"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",
@@ -571,6 +575,7 @@
"Share link revoked": "Share link revoked",
"Share link copied": "Share link copied",
"Share options": "Share options",
"Go to collection": "Go to collection",
"Go to document": "Go to document",
"Revoke link": "Revoke link",
"Contents": "Contents",
@@ -670,8 +675,6 @@
"Show contents": "Show contents",
"available when headings are added": "available when headings are added",
"Edit {{noun}}": "Edit {{noun}}",
"Switch to dark": "Switch to dark",
"Switch to light": "Switch to light",
"Archived": "Archived",
"Save draft": "Save draft",
"Done editing": "Done editing",
@@ -713,7 +716,6 @@
"Backlinks": "Backlinks",
"Close": "Close",
"This document is large which may affect performance": "This document is large which may affect performance",
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"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</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
@@ -1004,6 +1006,7 @@
"Guest": "Guest",
"Never used": "Never used",
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
"Title": "Title",
"Shared by": "Shared by",
"Date shared": "Date shared",
"Last accessed": "Last accessed",
@@ -1146,6 +1149,7 @@
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Alphabetical": "Alphabetical",
"There are no templates just yet.": "There are no templates just yet.",
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.",
"Confirmation code": "Confirmation code",
"Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.",