mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
7663c2a643
* Add backlinks support for publicly shared documents Include backlinks in the documents.info API response for publicly shared documents, filtering to only show backlinks that exist within the shared tree. Changes: - Add findSourceDocumentIdsInSharedTree method to Relationship model to find backlinks within allowed document IDs - Export getAllIdsInSharedTree helper from shareLoader for reuse - Update presentDocument to accept and include backlinkIds in response - Modify documents.info endpoint to fetch and include backlinks for public shares - Add backlinkIds property to Document model and update backlinks getter to use it when available 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor * refactor * wip * tsc * fix: Signed-out view throw, spacing * revert --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
258 lines
6.1 KiB
TypeScript
258 lines
6.1 KiB
TypeScript
import type { WhereOptions } from "sequelize";
|
|
import { Op } from "sequelize";
|
|
import isUUID from "validator/lib/isUUID";
|
|
import type { NavigationNode } from "@shared/types";
|
|
import { UrlHelper } from "@shared/utils/UrlHelper";
|
|
import {
|
|
AuthorizationError,
|
|
InvalidRequestError,
|
|
NotFoundError,
|
|
PaymentRequiredError,
|
|
} from "@server/errors";
|
|
import type { User } from "@server/models";
|
|
import { Collection, Document, Share } 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 InvalidRequestError("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 (sharedTree && share.domain) {
|
|
sharedTree.url = "";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (document?.isTrialImport) {
|
|
throw PaymentRequiredError();
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Recursively extracts all document IDs from a shared tree navigation node.
|
|
*
|
|
* @param sharedTree The navigation node representing the shared tree.
|
|
* @returns Array of all document IDs in the tree.
|
|
*/
|
|
export 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;
|
|
}
|