Files
outline/server/commands/documentLoader.ts
2023-04-23 12:20:44 -07:00

213 lines
5.6 KiB
TypeScript

import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
import {
NotFoundError,
InvalidRequestError,
AuthorizationError,
AuthenticationError,
} from "@server/errors";
import { Collection, Document, Share, User, Team } from "@server/models";
import { authorize, can } from "@server/policies";
type Props = {
id?: string;
shareId?: string;
teamId?: string;
user?: User;
includeState?: boolean;
};
type Result = {
document: Document;
share?: Share;
collection?: Collection | null;
};
export default async function loadDocument({
id,
shareId,
teamId,
user,
includeState,
}: Props): Promise<Result> {
let document;
let collection;
let share;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
}
const shareUrlId =
shareId && !isUUID(shareId) && SHARE_URL_SLUG_REGEX.test(shareId)
? shareId
: undefined;
if (shareUrlId && !teamId) {
throw InvalidRequestError(
"teamId required for fetching share using shareUrlId"
);
}
if (shareId) {
let whereClause: WhereOptions<Share> = {
revokedAt: {
[Op.is]: null,
},
id: shareId,
};
if (shareUrlId) {
whereClause = {
revokedAt: {
[Op.is]: null,
},
teamId,
urlId: shareUrlId,
};
}
share = await Share.findOne({
where: whereClause,
include: [
{
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
required: true,
as: "document",
},
],
});
if (!share || share.document?.archivedAt) {
throw InvalidRequestError("Document could not be found for shareId");
}
// It is possible to pass both an id and a shareId to the documents.info
// endpoint. In this case we'll load the document based on the `id` and check
// if the provided share token allows access. This is used by the frontend
// to navigate nested documents from a single share link.
if (id) {
document = await Document.findByPk(id, {
userId: user ? user.id : undefined,
paranoid: false,
}); // 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) {
document = await Document.findByPk(share.documentId, {
userId: user.id,
paranoid: false,
});
} else {
document = share.document;
}
if (!document) {
throw NotFoundError("Document could not be found for shareId");
}
// If the user has access to read the document, we can just update
// the last access date and return the document without additional checks.
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
// Cannot use document.collection here as it does not include the
// documentStructure by default through the relationship.
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
}
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
return {
document,
share,
collection,
};
}
// "published" === on the public internet.
// We already know that there's either no logged in user or the user doesn't
// have permission to read the document, so we can throw an error.
if (!share.published) {
throw AuthorizationError();
}
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.findByPk(document.collectionId);
}
invariant(collection, "collection not found");
if (!collection.sharing) {
throw AuthorizationError();
}
// 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.includeChildDocuments) {
throw AuthorizationError();
}
const childDocumentIds =
(await share.document?.getChildDocumentIds({
archivedAt: {
[Op.is]: null,
},
})) ?? [];
if (!childDocumentIds.includes(document.id)) {
throw AuthorizationError();
}
}
// It is possible to disable sharing at the team level so we must check
const team = await Team.findByPk(document.teamId, { rejectOnEmpty: true });
if (!team.sharing) {
throw AuthorizationError();
}
} else {
document = await Document.findByPk(id as string, {
userId: user ? user.id : undefined,
paranoid: false,
includeState,
});
if (!document) {
throw NotFoundError();
}
if (document.deletedAt) {
// don't send data if user cannot restore deleted doc
user && authorize(user, "restore", document);
} else {
user && authorize(user, "read", document);
}
collection = document.collection;
}
return {
document,
share,
collection,
};
}