Files
outline/server/commands/shareLoader.ts
T
Hemachandar d3eb3db7ba feat: Public sharing of collections (#9529)
* shares.info, collections.info, documents.info

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

* shares.sitemap

* parity with existing document shared screen

* collection share popover

* parent share and table

* collection scene

* collection link in sidebar

* sidebar and breadcrumb collection link click

* collection link click in editor

* meta

* more meta + 404 page

* map internal link, remove showLastUpdated option

* fix shares.list pagination

* show last updated

* shareLoader tests

* lint

* sidebar context for collection link

* badge in shares table

* fix existing tests

* tsc

* update failing test snapshot

* env

* signed url for collection attachments

* include collection content in SSR for screen readers

* search

* drafts can be shared

* review

* tsc, remove old shared-doc scene

* tweaks

* DRY

* refactor loader

* Remove share/collection urls

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

* Tweak public breadcrumb

* fix: Deleted documents should never be exposed through share

* empty sharedTree array where includeChildDocuments is false

* revert includeChildDocs guard for logical correctness + SSR bug fix

* fix: check document is part of share

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-08-03 13:07:39 -04:00

235 lines
5.6 KiB
TypeScript

import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { NavigationNode } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { AuthorizationError, NotFoundError } from "@server/errors";
import { Collection, Document, Share, User } from "@server/models";
import { authorize, can } from "@server/policies";
type LoadPublicShareProps = {
id: string;
collectionId?: string;
documentId?: string;
teamId?: string;
};
export async function loadPublicShare({
id,
collectionId,
documentId,
teamId,
}: LoadPublicShareProps) {
const urlId =
!isUUID(id) && UrlHelper.SHARE_URL_SLUG_REGEX.test(id) ? id : undefined;
if (urlId && !teamId) {
throw new Error("teamId required for fetching share using urlId");
}
const where: WhereOptions<Share> = {
revokedAt: {
[Op.is]: null,
},
published: true,
};
if (urlId) {
where.urlId = id;
where.teamId = teamId;
} else {
where.id = id;
}
const share = await Share.findOne({
where,
include: [
{
model: Document.scope("withDrafts"),
as: "document",
include: [
{
model: Collection.scope("withDocumentStructure"),
as: "collection",
required: false,
},
],
},
{
model: Collection.scope("withDocumentStructure"),
as: "collection",
},
],
});
if (
!share ||
!!share.team.suspendedAt ||
!!share.collection?.archivedAt ||
!!share.document?.archivedAt
) {
throw NotFoundError();
}
const isDraftWithoutCollection =
!!share.document?.isDraft && !share.document.collectionId;
const associatedCollection = share.collection ?? share.document?.collection;
if (
!share.team.sharing ||
(!isDraftWithoutCollection && !associatedCollection?.sharing)
) {
throw AuthorizationError();
}
let sharedTree: NavigationNode | null = null;
let document: Document | null = null;
if (share.collection) {
sharedTree = associatedCollection?.toNavigationNode() ?? null;
} else if (share.document && share.includeChildDocuments) {
sharedTree =
associatedCollection?.getDocumentTree(share.document.id) ?? null;
}
if (collectionId && collectionId !== share.collectionId) {
throw AuthorizationError();
}
if (documentId && documentId !== share.documentId) {
document = await Document.findByPk(documentId, {
rejectOnEmpty: true,
});
let isDocumentAccessible = share.documentId === document.id;
if (share.includeChildDocuments) {
const allIdsInSharedTree = getAllIdsInSharedTree(sharedTree);
isDocumentAccessible = allIdsInSharedTree.includes(document.id);
}
if (!isDocumentAccessible) {
throw AuthorizationError();
}
} else {
document = share.document;
}
return {
share,
sharedTree,
collection: share.collection,
document,
};
}
type LoadShareWithParentProps = {
collectionId?: string;
documentId?: string;
user: User;
};
export async function loadShareWithParent({
collectionId,
documentId,
user,
}: LoadShareWithParentProps) {
const where: WhereOptions<Share> = {
revokedAt: {
[Op.is]: null,
},
teamId: user.teamId,
};
if (collectionId) {
where.collectionId = collectionId;
} else if (documentId) {
where.documentId = documentId;
}
const share = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findOne({ where });
if (!share) {
throw NotFoundError();
}
authorize(user, "read", share);
if (collectionId) {
authorize(user, "read", share.collection);
}
let parentShare: Share | null = null;
// Load the parent shares and return one (needed for share toggle in UI).
// Parent share is needed for documents only since collections don't have parents.
if (documentId) {
authorize(user, "read", share.document);
const docCollectionId = share.document.collectionId;
if (!docCollectionId) {
throw NotFoundError("Collection not found for the shared document");
}
const docCollection = await Collection.findByPk(docCollectionId, {
userId: user.id,
includeDocumentStructure: true,
rejectOnEmpty: true,
});
const collectionShare = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findOne({
where: {
revokedAt: {
[Op.is]: null,
},
published: true,
teamId: user.teamId,
collectionId: docCollectionId,
},
});
// prefer collection share if it exists and user has read access.
if (collectionShare && can(user, "read", collectionShare)) {
parentShare = collectionShare;
} else {
const parentDocIds = docCollection.getDocumentParents(documentId);
const allParentShares = parentDocIds
? await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findAll({
where: {
revokedAt: {
[Op.is]: null,
},
published: true,
teamId: user.teamId,
includeChildDocuments: true,
documentId: parentDocIds,
},
})
: null;
parentShare = allParentShares?.find((s) => can(user, "read", s)) ?? null;
}
}
return { share, parentShare };
}
function getAllIdsInSharedTree(sharedTree: NavigationNode | null): string[] {
if (!sharedTree) {
return [];
}
const ids = [sharedTree.id];
for (const child of sharedTree.children) {
ids.push(...getAllIdsInSharedTree(child));
}
return ids;
}