From d3eb3db7ba8e5dc33197a8b422cca704a9638e5d Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Sun, 3 Aug 2025 22:37:39 +0530 Subject: [PATCH] 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 --- app/components/SearchListItem.tsx | 4 +- .../Sharing/Collection/AccessControlList.tsx | 24 +- .../Sharing/Collection/PublicAccess.tsx | 267 ++++++++++++++ .../Sharing/Collection/SharePopover.tsx | 8 +- .../Sharing/Document/PublicAccess.tsx | 134 +++---- .../Sharing/Document/SharePopover.tsx | 2 +- app/components/Sharing/components/Actions.tsx | 34 ++ app/components/Sidebar/Shared.tsx | 45 ++- .../components/SharedCollectionLink.tsx | 53 +++ .../Sidebar/components/SharedDocumentLink.tsx | 10 +- app/hooks/useEditorClickHandlers.ts | 7 +- app/menus/ShareMenu.tsx | 11 +- app/models/Collection.ts | 7 + app/models/Document.ts | 3 +- app/models/Share.ts | 37 +- app/routes/index.tsx | 25 +- app/scenes/Collection/components/Overview.tsx | 10 +- app/scenes/Collection/index.tsx | 13 +- app/scenes/Document/Shared.tsx | 262 ------------- app/scenes/Document/components/DataLoader.tsx | 2 +- app/scenes/Document/components/Header.tsx | 26 +- .../Document/components/PublicBreadcrumb.tsx | 6 +- .../Document/components/ReferenceListItem.tsx | 4 +- .../Document/components/ShareButton.tsx | 2 +- .../Settings/components/SharesTable.tsx | 12 +- app/scenes/Shared/Collection.tsx | 107 ++++++ app/scenes/Shared/Document.tsx | 37 ++ app/scenes/Shared/index.tsx | 257 +++++++++++++ app/stores/SharesStore.ts | 92 ++++- app/utils/ApiClient.ts | 10 +- app/utils/routeHelpers.test.ts | 6 +- app/utils/routeHelpers.ts | 9 +- plugins/slack/server/auth/slack.test.ts | 5 +- server/commands/documentLoader.ts | 24 +- server/commands/shareLoader.test.ts | 343 ++++++++++++++++++ server/commands/shareLoader.ts | 234 ++++++++++++ ...50630175759-add-collection-id-to-shares.js | 37 ++ server/models/Collection.ts | 39 ++ server/models/Share.ts | 38 +- server/models/helpers/DocumentHelper.tsx | 25 +- server/models/helpers/ProsemirrorHelper.tsx | 4 +- server/models/helpers/SearchHelper.ts | 33 +- server/policies/share.ts | 5 +- server/presenters/collection.ts | 41 ++- server/presenters/share.ts | 3 + server/queues/tasks/ExportJSONTask.ts | 4 +- server/routes/api/collections/schema.ts | 10 +- server/routes/api/documents/documents.ts | 42 ++- .../shares/__snapshots__/shares.test.ts.snap | 9 - server/routes/api/shares/schema.ts | 71 ++-- server/routes/api/shares/shares.test.ts | 112 +++--- server/routes/api/shares/shares.ts | 242 +++++++----- server/routes/app.ts | 64 ++-- server/test/factories.ts | 2 +- shared/i18n/locales/en_US/translation.json | 32 +- 55 files changed, 2237 insertions(+), 708 deletions(-) create mode 100644 app/components/Sharing/Collection/PublicAccess.tsx create mode 100644 app/components/Sharing/components/Actions.tsx create mode 100644 app/components/Sidebar/components/SharedCollectionLink.tsx delete mode 100644 app/scenes/Document/Shared.tsx create mode 100644 app/scenes/Shared/Collection.tsx create mode 100644 app/scenes/Shared/Document.tsx create mode 100644 app/scenes/Shared/index.tsx create mode 100644 server/commands/shareLoader.test.ts create mode 100644 server/commands/shareLoader.ts create mode 100644 server/migrations/20250630175759-add-collection-id-to-shares.js diff --git a/app/components/SearchListItem.tsx b/app/components/SearchListItem.tsx index 5ee7996c98..dd26d1f829 100644 --- a/app/components/SearchListItem.tsx +++ b/app/components/SearchListItem.tsx @@ -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, diff --git a/app/components/Sharing/Collection/AccessControlList.tsx b/app/components/Sharing/Collection/AccessControlList.tsx index c2ddc1ec67..8b1fb0e191 100644 --- a/app/components/Sharing/Collection/AccessControlList.tsx +++ b/app/components/Sharing/Collection/AccessControlList.tsx @@ -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 && ( + + {collection.members.length ? : null} + + + )} ); } @@ -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; +`; diff --git a/app/components/Sharing/Collection/PublicAccess.tsx b/app/components/Sharing/Collection/PublicAccess.tsx new file mode 100644 index 0000000000..5314074f6c --- /dev/null +++ b/app/components/Sharing/Collection/PublicAccess.tsx @@ -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(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 = ( + + + + + + + + ); + + return ( + + {t("Allow anyone with the link to access")}} + image={ + + + + } + actions={ + + } + /> + + + {!!share?.published && ( + <> + + {t("Search engine indexing")}  + + + + + + + } + actions={ + + } + /> + + {t("Show last modified")}  + + + + + + + } + actions={ + + } + /> + inputRef.current?.focus()}> + {env.URL.replace(/https?:\/\//, "") + "/s/"} + + } + > + {copyButton} + + + + + {t( + "All documents in this collection will be shared on the web, including any new documents added later" + )} + . + + + + )} + + + ); +} + +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); diff --git a/app/components/Sharing/Collection/SharePopover.tsx b/app/components/Sharing/Collection/SharePopover.tsx index 0d76743dcf..10bbb01748 100644 --- a/app/components/Sharing/Collection/SharePopover.tsx +++ b/app/components/Sharing/Collection/SharePopover.tsx @@ -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(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) {
diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx index 97452d366e..d856cbb43f 100644 --- a/app/components/Sharing/Document/PublicAccess.tsx +++ b/app/components/Sharing/Document/PublicAccess.tsx @@ -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 ? ( - - Anyone with the link can access because the parent document,{" "} - - {{ documentTitle }} - - , is shared - + sharedParent.collectionId ? ( + + Anyone with the link can access because the containing + collection,{" "} + + {sharedParent.sourceTitle} + + , is shared + + ) : ( + + Anyone with the link can access because the parent document,{" "} + + {sharedParent.sourceTitle} + + , is shared + + ) ) : ( t("Allow anyone with the link to access") )} @@ -180,60 +189,59 @@ function PublicAccess({ document, share, sharedParent }: Props) { /> - {share?.published && ( - - {t("Search engine indexing")}  - - - - - - - } - actions={ - - } - /> - )} - - {share?.published && ( - - {t("Show last modified")}  - - - - - - - } - actions={ - - } - /> + {share?.published && !sharedParent?.published && ( + <> + + {t("Search engine indexing")}  + + + + + + + } + actions={ + + } + /> + + {t("Show last modified")}  + + + + + + + } + actions={ + + } + /> + )} {sharedParent?.published ? ( diff --git a/app/components/Sharing/Document/SharePopover.tsx b/app/components/Sharing/Document/SharePopover.tsx index 007633977c..e6715e0757 100644 --- a/app/components/Sharing/Document/SharePopover.tsx +++ b/app/components/Sharing/Document/SharePopover.tsx @@ -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(""); diff --git a/app/components/Sharing/components/Actions.tsx b/app/components/Sharing/components/Actions.tsx new file mode 100644 index 0000000000..ea69af6989 --- /dev/null +++ b/app/components/Sharing/components/Actions.tsx @@ -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 ( + + + + + + ); + + return ( + } + title={ + <> + +  {collection.name} + + } + actions={ + <> + + {can.update ? editAction : null} + + } + > + + + + + + + {collection.name} + + {!!shareId && !!collection.updatedAt ? ( + + {t("Last updated")}{" "} + + ) : null} + + + + + ); +} + +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); diff --git a/app/scenes/Shared/Document.tsx b/app/scenes/Shared/Document.tsx new file mode 100644 index 0000000000..21f2065101 --- /dev/null +++ b/app/scenes/Shared/Document.tsx @@ -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 ( + + ); +} + +export const Document = observer(SharedDocument); diff --git a/app/scenes/Shared/index.tsx b/app/scenes/Shared/index.tsx new file mode 100644 index 0000000000..8277c065c2 --- /dev/null +++ b/app/scenes/Shared/index.tsx @@ -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(); + + 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(); + + 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(); + const location = useLocation(); + 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 ; + } + + if (error) { + if (error instanceof OfflineError) { + return ; + } + if (error instanceof AuthorizationError) { + setPostLoginPath(location.pathname); + return ( + + {(config) => + config?.name && isCloudHosted ? ( + + {t( + "{{ teamName }} is using {{ appName }} to share documents, please login to continue.", + { + teamName: config.name, + appName: env.APP_NAME, + } + )} + + ) : null + } + + ); + } + return ; + } + + if (!share) { + return ; + } + + return ( + <> + + + + + + + + }> + {model instanceof Document ? ( + + ) : model instanceof Collection ? ( + + ) : null} + + + + + + + ); +} + +const Content = styled(Text)` + color: ${s("textSecondary")}; + text-align: center; + margin-top: -8px; +`; + +export default observer(SharedScene); diff --git a/app/stores/SharesStore.ts b/app/stores/SharesStore.ts index a0272117b3..23b05df853 100644 --- a/app/stores/SharesStore.ts +++ b/app/stores/SharesStore.ts @@ -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 { 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 { }; @action - async create(params: Required, "documentId">) { - const item = this.getByDocumentId(params.documentId); + async create( + params: + | (PartialExcept & { type: "collection" }) + | (PartialExcept & { type: "document" }) + ): Promise { + 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 { - 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 { } } - 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 { } const parentIds = collection - .pathToDocument(documentId) + .pathToDocument(document.id) .slice(0, -1) .map((p) => p.id); diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index 67b39eb825..69a1b2eab9 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -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(); } diff --git a/app/utils/routeHelpers.test.ts b/app/utils/routeHelpers.test.ts index 49dd176bbf..afdea8e9e9 100644 --- a/app/utils/routeHelpers.test.ts +++ b/app/utils/routeHelpers.test.ts @@ -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" ); }); diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index c4f8911996..6b98339d48 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -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})"; diff --git a/plugins/slack/server/auth/slack.test.ts b/plugins/slack/server/auth/slack.test.ts index 28b9819a56..eee753cbb0 100644 --- a/plugins/slack/server/auth/slack.test.ts +++ b/plugins/slack/server/auth/slack.test.ts @@ -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 }) => { diff --git a/server/commands/documentLoader.ts b/server/commands/documentLoader.ts index a83fc9eb45..d8c95c6ff9 100644 --- a/server/commands/documentLoader.ts +++ b/server/commands/documentLoader.ts @@ -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(); } diff --git a/server/commands/shareLoader.test.ts b/server/commands/shareLoader.test.ts new file mode 100644 index 0000000000..1c483a51d3 --- /dev/null +++ b/server/commands/shareLoader.test.ts @@ -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(); + }); + }); +}); diff --git a/server/commands/shareLoader.ts b/server/commands/shareLoader.ts new file mode 100644 index 0000000000..bb9b59e1f6 --- /dev/null +++ b/server/commands/shareLoader.ts @@ -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 = { + 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 = { + 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; +} diff --git a/server/migrations/20250630175759-add-collection-id-to-shares.js b/server/migrations/20250630175759-add-collection-id-to-shares.js new file mode 100644 index 0000000000..1ddc53601e --- /dev/null +++ b/server/migrations/20250630175759-add-collection-id-to-shares.js @@ -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 } + ); + }); + }, +}; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index f48bbc0190..5a8dc8aa8e 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -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; diff --git a/server/models/Share.ts b/server/models/Share.ts index 5277f44bf1..03219ac8c3 100644 --- a/server/models/Share.ts +++ b/server/models/Share.ts @@ -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; diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 24ca0b281b..4e81d79f83 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -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; diff --git a/server/models/helpers/ProsemirrorHelper.tsx b/server/models/helpers/ProsemirrorHelper.tsx index 802da24ad2..7bbeaf0be8 100644 --- a/server/models/helpers/ProsemirrorHelper.tsx +++ b/server/models/helpers/ProsemirrorHelper.tsx @@ -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; diff --git a/server/models/helpers/SearchHelper.ts b/server/models/helpers/SearchHelper.ts index f2e89d801a..02d023a5fe 100644 --- a/server/models/helpers/SearchHelper.ts +++ b/server/models/helpers/SearchHelper.ts @@ -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, }); } diff --git a/server/policies/share.ts b/server/policies/share.ts index 018b5bba6b..4f3575d473 100644 --- a/server/policies/share.ts +++ b/server/policies/share.ts @@ -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) + ) ) ); diff --git a/server/presenters/collection.ts b/server/presenters/collection.ts index 661f5c7621..0b370301cb 100644 --- a/server/presenters/collection.ts +++ b/server/presenters/collection.ts @@ -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 = { 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; } diff --git a/server/presenters/share.ts b/server/presenters/share.ts index a05105e1d9..62b40bf370 100644 --- a/server/presenters/share.ts +++ b/server/presenters/share.ts @@ -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, diff --git a/server/queues/tasks/ExportJSONTask.ts b/server/queues/tasks/ExportJSONTask.ts index ae6196385a..08763e8bb7 100644 --- a/server/queues/tasks/ExportJSONTask.ts +++ b/server/queues/tasks/ExportJSONTask.ts @@ -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: {}, diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts index b1a7e52ac2..1fe0968164 100644 --- a/server/routes/api/collections/schema.ts +++ b/server/routes/api/collections/schema.ts @@ -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; 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; diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index d797841632..64e845ce1f 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -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, diff --git a/server/routes/api/shares/__snapshots__/shares.test.ts.snap b/server/routes/api/shares/__snapshots__/shares.test.ts.snap index dcae0f2509..dee1c4e30a 100644 --- a/server/routes/api/shares/__snapshots__/shares.test.ts.snap +++ b/server/routes/api/shares/__snapshots__/shares.test.ts.snap @@ -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", diff --git a/server/routes/api/shares/schema.ts b/server/routes/api/shares/schema.ts index 78532b9396..117614c9f5 100644 --- a/server/routes/api/shares/schema.ts +++ b/server/routes/api/shares/schema.ts @@ -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; @@ -66,23 +66,24 @@ export const SharesUpdateSchema = BaseSchema.extend({ export type SharesUpdateReq = z.infer; 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; @@ -94,3 +95,11 @@ export const SharesRevokeSchema = BaseSchema.extend({ }); export type SharesRevokeReq = z.infer; + +export const SharesSitemapSchema = BaseSchema.extend({ + query: z.object({ + id: z.string(), + }), +}); + +export type SharesSitemapReq = z.infer; diff --git a/server/routes/api/shares/shares.test.ts b/server/routes/api/shares/shares.test.ts index 6a793d3d76..98c67d19f1 100644 --- a/server/routes/api/shares/shares.test.ts +++ b/server/routes/api/shares/shares.test.ts @@ -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(); diff --git a/server/routes/api/shares/shares.ts b/server/routes/api/shares/shares.ts index b7a32370bb..9eef9d2759 100644 --- a/server/routes/api/shares/shares.ts +++ b/server/routes/api/shares/shares.ts @@ -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) => { - 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 = { + const collectionWhere: WhereAttributeHash = { + "$collection.id$": collectionIds, + "$collection.teamId$": user.teamId, + }; + + const documentWhere: WhereAttributeHash = { + "$document.teamId$": user.teamId, + "$document.collectionId$": collectionIds, + }; + + if (query) { + collectionWhere["$collection.name$"] = { [Op.iLike]: `%${query}%` }; + documentWhere["$document.title$"] = { + [Op.iLike]: `%${query}%`, + }; + } + + const shareWhere: WhereOptions = { teamId: user.teamId, userId: user.id, published: true, @@ -117,30 +155,28 @@ router.post( }, }; - const documentWhere: WhereOptions = { - 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) => { 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) => { + 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; diff --git a/server/routes/app.ts b/server/routes/app.ts index e49a6559ad..6a676b7f3a 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -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[] = []; 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, }); }; diff --git a/server/test/factories.ts b/server/test/factories.ts index 155bc0d872..c33056e1a0 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -77,7 +77,7 @@ export async function buildShare(overrides: Partial = {}) { overrides.userId = user.id; } - if (!overrides.documentId) { + if (!overrides.documentId && !overrides.collectionId) { const document = await buildDocument({ createdById: overrides.userId, teamId: overrides.teamId, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index bce3edbb77..ce67c07192 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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> ago": "Active <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}}, is shared": "Anyone with the link can access because the parent document, <2>{{documentTitle}}, 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}, is shared": "Anyone with the link can access because the containing collection, <2>{sharedParent.sourceTitle}, is shared", + "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}, is shared": "Anyone with the link can access because the parent document, <2>{sharedParent.sourceTitle}, 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 {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?", "Are you sure about that? Deleting the {{ documentTitle }} document will delete all of its history.": "Are you sure about that? Deleting the {{ documentTitle }} document will delete all of its history.", "Are you sure about that? Deleting the {{ documentTitle }} document will delete all of its history and {{ any }} nested document.": "Are you sure about that? Deleting the {{ documentTitle }} document will delete all of its history and {{ any }} nested document.", @@ -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}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}} workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.",