mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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")}
|
||||
<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")}
|
||||
<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")}
|
||||
<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")}
|
||||
<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")}
|
||||
<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")}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 />
|
||||
{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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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})";
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
Reference in New Issue
Block a user