Individual document sharing with permissions (#5814)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import map from "lodash/map";
|
||||
import queryParser from "pg-tsquery";
|
||||
import { Op, QueryTypes, WhereOptions } from "sequelize";
|
||||
import { Op, Sequelize, WhereOptions } from "sequelize";
|
||||
import { DateFilter } from "@shared/types";
|
||||
import Collection from "@server/models/Collection";
|
||||
import Document from "@server/models/Document";
|
||||
@@ -48,7 +48,7 @@ type SearchOptions = {
|
||||
snippetMaxWords?: number;
|
||||
};
|
||||
|
||||
type Results = {
|
||||
type RankedDocument = Document & {
|
||||
searchRanking: number;
|
||||
searchContext: string;
|
||||
id: string;
|
||||
@@ -72,25 +72,7 @@ export default class SearchHelper {
|
||||
offset = 0,
|
||||
} = options;
|
||||
|
||||
// restrict to specific collection if provided
|
||||
// enables search in private collections if specified
|
||||
let collectionIds: string[];
|
||||
if (options.collectionId) {
|
||||
collectionIds = [options.collectionId];
|
||||
} else {
|
||||
collectionIds = await team.collectionIds();
|
||||
}
|
||||
|
||||
// short circuit if no relevant collections
|
||||
if (!collectionIds.length) {
|
||||
return {
|
||||
results: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// restrict to documents in the tree of a shared document when one is provided
|
||||
let documentIds: string[] | undefined;
|
||||
const where = await this.buildWhere(team, options);
|
||||
|
||||
if (options.share?.includeChildDocuments) {
|
||||
const sharedDocument = await options.share.$get("document");
|
||||
@@ -101,57 +83,57 @@ export default class SearchHelper {
|
||||
[Op.is]: null,
|
||||
},
|
||||
});
|
||||
documentIds = [sharedDocument.id, ...childDocumentIds];
|
||||
|
||||
where[Op.and].push({
|
||||
id: [sharedDocument.id, ...childDocumentIds],
|
||||
});
|
||||
}
|
||||
|
||||
const documentClause = documentIds ? `"id" IN(:documentIds) AND` : "";
|
||||
where[Op.and].push(
|
||||
Sequelize.fn(
|
||||
`"searchVector" @@ to_tsquery`,
|
||||
"english",
|
||||
Sequelize.literal(":query")
|
||||
)
|
||||
);
|
||||
|
||||
// Build the SQL query to get result documentIds, ranking, and search term context
|
||||
const whereClause = `
|
||||
"searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
"collectionId" IN(:collectionIds) AND
|
||||
${documentClause}
|
||||
"deletedAt" IS NULL AND
|
||||
"publishedAt" IS NOT NULL
|
||||
`;
|
||||
const selectSql = `
|
||||
SELECT
|
||||
id,
|
||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||
ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions) as "searchContext"
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
ORDER BY
|
||||
"searchRanking" DESC,
|
||||
"updatedAt" DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
`;
|
||||
const countSql = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const queryReplacements = {
|
||||
teamId: team.id,
|
||||
query: this.webSearchQuery(query),
|
||||
collectionIds,
|
||||
documentIds,
|
||||
headlineOptions: `MaxFragments=1, MinWords=${snippetMinWords}, MaxWords=${snippetMaxWords}`,
|
||||
};
|
||||
const resultsQuery = sequelize.query<Results>(selectSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { ...queryReplacements, limit, offset },
|
||||
});
|
||||
const countQuery = sequelize.query<{ count: number }>(countSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
|
||||
const resultsQuery = Document.unscoped().findAll({
|
||||
attributes: [
|
||||
"id",
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_rank("searchVector", to_tsquery('english', :query))`
|
||||
),
|
||||
"searchRanking",
|
||||
],
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions)`
|
||||
),
|
||||
"searchContext",
|
||||
],
|
||||
],
|
||||
replacements: queryReplacements,
|
||||
});
|
||||
const [results, [{ count }]] = await Promise.all([
|
||||
resultsQuery,
|
||||
countQuery,
|
||||
]);
|
||||
where,
|
||||
order: [
|
||||
["searchRanking", "DESC"],
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
limit,
|
||||
offset,
|
||||
}) as any as Promise<RankedDocument[]>;
|
||||
|
||||
const countQuery = Document.unscoped().count({
|
||||
// @ts-expect-error Types are incorrect for count
|
||||
replacements: queryReplacements,
|
||||
where,
|
||||
}) as any as Promise<number>;
|
||||
const [results, count] = await Promise.all([resultsQuery, countQuery]);
|
||||
|
||||
// Final query to get associated document data
|
||||
const documents = await Document.findAll({
|
||||
@@ -167,7 +149,7 @@ export default class SearchHelper {
|
||||
],
|
||||
});
|
||||
|
||||
return SearchHelper.buildResponse(results, documents, count);
|
||||
return this.buildResponse(results, documents, count);
|
||||
}
|
||||
|
||||
public static async searchTitlesForUser(
|
||||
@@ -176,88 +158,36 @@ export default class SearchHelper {
|
||||
options: SearchOptions = {}
|
||||
): Promise<Document[]> {
|
||||
const { limit = 15, offset = 0 } = options;
|
||||
const where = await this.buildWhere(user, options);
|
||||
|
||||
const where: WhereOptions<Document> = {
|
||||
teamId: user.teamId,
|
||||
where[Op.and].push({
|
||||
title: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
[Op.and]: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Ensure we're filtering by the users accessible collections. If
|
||||
// collectionId is passed as an option it is assumed that the authorization
|
||||
// has already been done in the router
|
||||
if (options.collectionId) {
|
||||
where[Op.and].push({
|
||||
collectionId: options.collectionId,
|
||||
});
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
collectionId: {
|
||||
[Op.in]: await user.collectionIds(),
|
||||
},
|
||||
},
|
||||
{
|
||||
collectionId: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
createdById: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (options.dateFilter) {
|
||||
where[Op.and].push({
|
||||
updatedAt: {
|
||||
[Op.gt]: sequelize.literal(
|
||||
`now() - interval '1 ${options.dateFilter}'`
|
||||
),
|
||||
const include = [
|
||||
{
|
||||
association: "memberships",
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
required: false,
|
||||
separate: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (!options.includeArchived) {
|
||||
where[Op.and].push({
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeDrafts) {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
createdById: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.collaboratorIds) {
|
||||
where[Op.and].push({
|
||||
collaboratorIds: {
|
||||
[Op.contains]: options.collaboratorIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await Document.scope([
|
||||
return Document.scope([
|
||||
"withoutState",
|
||||
"withDrafts",
|
||||
{
|
||||
@@ -266,21 +196,14 @@ export default class SearchHelper {
|
||||
{
|
||||
method: ["withCollectionPermissions", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
where,
|
||||
subQuery: false,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
include,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
@@ -297,90 +220,69 @@ export default class SearchHelper {
|
||||
limit = 15,
|
||||
offset = 0,
|
||||
} = options;
|
||||
// Ensure we're filtering by the users accessible collections. If
|
||||
// collectionId is passed as an option it is assumed that the authorization
|
||||
// has already been done in the router
|
||||
let collectionIds;
|
||||
|
||||
if (options.collectionId) {
|
||||
collectionIds = [options.collectionId];
|
||||
} else {
|
||||
collectionIds = await user.collectionIds();
|
||||
}
|
||||
const where = await this.buildWhere(user, options);
|
||||
|
||||
let dateFilter;
|
||||
where[Op.and].push(
|
||||
Sequelize.fn(
|
||||
`"searchVector" @@ to_tsquery`,
|
||||
"english",
|
||||
Sequelize.literal(":query")
|
||||
)
|
||||
);
|
||||
|
||||
if (options.dateFilter) {
|
||||
dateFilter = `1 ${options.dateFilter}`;
|
||||
}
|
||||
|
||||
// Build the SQL query to get documentIds, ranking, and search term context
|
||||
const whereClause = `
|
||||
"searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
${
|
||||
collectionIds.length
|
||||
? `(
|
||||
"collectionId" IN(:collectionIds) OR
|
||||
("collectionId" IS NULL AND "createdById" = :userId)
|
||||
) AND`
|
||||
: '"collectionId" IS NULL AND "createdById" = :userId AND'
|
||||
}
|
||||
${
|
||||
options.dateFilter ? '"updatedAt" > now() - interval :dateFilter AND' : ""
|
||||
}
|
||||
${
|
||||
options.collaboratorIds
|
||||
? '"collaboratorIds" @> ARRAY[:collaboratorIds]::uuid[] AND'
|
||||
: ""
|
||||
}
|
||||
${options.includeArchived ? "" : '"archivedAt" IS NULL AND'}
|
||||
"deletedAt" IS NULL AND
|
||||
${
|
||||
options.includeDrafts
|
||||
? '("publishedAt" IS NOT NULL OR "createdById" = :userId)'
|
||||
: '"publishedAt" IS NOT NULL'
|
||||
}
|
||||
`;
|
||||
const selectSql = `
|
||||
SELECT
|
||||
id,
|
||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||
ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions) as "searchContext"
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
ORDER BY
|
||||
"searchRanking" DESC,
|
||||
"updatedAt" DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
`;
|
||||
const countSql = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const queryReplacements = {
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collaboratorIds: options.collaboratorIds,
|
||||
query: this.webSearchQuery(query),
|
||||
collectionIds,
|
||||
dateFilter,
|
||||
headlineOptions: `MaxFragments=1, MinWords=${snippetMinWords}, MaxWords=${snippetMaxWords}`,
|
||||
};
|
||||
const resultsQuery = sequelize.query<Results>(selectSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { ...queryReplacements, limit, offset },
|
||||
});
|
||||
const countQuery = sequelize.query<{ count: number }>(countSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
|
||||
const include = [
|
||||
{
|
||||
association: "memberships",
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
required: false,
|
||||
separate: false,
|
||||
},
|
||||
];
|
||||
|
||||
const resultsQuery = Document.unscoped().findAll({
|
||||
attributes: [
|
||||
"id",
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_rank("searchVector", to_tsquery('english', :query))`
|
||||
),
|
||||
"searchRanking",
|
||||
],
|
||||
[
|
||||
Sequelize.literal(
|
||||
`ts_headline('english', "text", to_tsquery('english', :query), :headlineOptions)`
|
||||
),
|
||||
"searchContext",
|
||||
],
|
||||
],
|
||||
subQuery: false,
|
||||
include,
|
||||
replacements: queryReplacements,
|
||||
});
|
||||
const [results, [{ count }]] = await Promise.all([
|
||||
resultsQuery,
|
||||
countQuery,
|
||||
]);
|
||||
where,
|
||||
order: [
|
||||
["searchRanking", "DESC"],
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
limit,
|
||||
offset,
|
||||
}) as any as Promise<RankedDocument[]>;
|
||||
|
||||
const countQuery = Document.unscoped().count({
|
||||
// @ts-expect-error Types are incorrect for count
|
||||
subQuery: false,
|
||||
include,
|
||||
replacements: queryReplacements,
|
||||
where,
|
||||
}) as any as Promise<number>;
|
||||
const [results, count] = await Promise.all([resultsQuery, countQuery]);
|
||||
|
||||
// Final query to get associated document data
|
||||
const documents = await Document.scope([
|
||||
@@ -392,6 +294,9 @@ export default class SearchHelper {
|
||||
{
|
||||
method: ["withCollectionPermissions", user.id],
|
||||
},
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
@@ -399,11 +304,91 @@ export default class SearchHelper {
|
||||
},
|
||||
});
|
||||
|
||||
return SearchHelper.buildResponse(results, documents, count);
|
||||
return this.buildResponse(results, documents, count);
|
||||
}
|
||||
|
||||
private static async buildWhere(model: User | Team, options: SearchOptions) {
|
||||
const teamId = model instanceof Team ? model.id : model.teamId;
|
||||
const where: WhereOptions<Document> = {
|
||||
teamId,
|
||||
[Op.or]: [],
|
||||
[Op.and]: [
|
||||
{
|
||||
deletedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (model instanceof User) {
|
||||
where[Op.or].push({ "$memberships.id$": { [Op.ne]: null } });
|
||||
}
|
||||
|
||||
// Ensure we're filtering by the users accessible collections. If
|
||||
// collectionId is passed as an option it is assumed that the authorization
|
||||
// has already been done in the router
|
||||
const collectionIds = options.collectionId
|
||||
? [options.collectionId]
|
||||
: await model.collectionIds();
|
||||
|
||||
if (collectionIds.length) {
|
||||
where[Op.or].push({ collectionId: collectionIds });
|
||||
}
|
||||
|
||||
if (options.dateFilter) {
|
||||
where[Op.and].push({
|
||||
updatedAt: {
|
||||
[Op.gt]: sequelize.literal(
|
||||
`now() - interval '1 ${options.dateFilter}'`
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.collaboratorIds) {
|
||||
where[Op.and].push({
|
||||
collaboratorIds: {
|
||||
[Op.contains]: options.collaboratorIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.includeArchived) {
|
||||
where[Op.and].push({
|
||||
archivedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeDrafts && model instanceof User) {
|
||||
where[Op.and].push({
|
||||
[Op.or]: [
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
createdById: model.id,
|
||||
},
|
||||
{ "$memberships.id$": { [Op.ne]: null } },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
where[Op.and].push({
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
private static buildResponse(
|
||||
results: Results[],
|
||||
results: RankedDocument[],
|
||||
documents: Document[],
|
||||
count: number
|
||||
): SearchResponse {
|
||||
|
||||
Reference in New Issue
Block a user