Request validation for /api/shares.* (#5467)

* chore: req validation for shares.info

* chore: req validation for shares.list

* chore: req validation for shares.update

* chore: req validation for shares.create

* chore: req validation for shares.revoke

* fix: review
This commit is contained in:
Apoorv Mishra
2023-06-25 17:50:23 +05:30
committed by GitHub
parent 4e69ae1ffe
commit 86d6117a31
6 changed files with 583 additions and 377 deletions

View File

@@ -1,304 +0,0 @@
import Router from "koa-router";
import { isUndefined } from "lodash";
import { Op, WhereOptions } from "sequelize";
import { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { Document, User, Event, Share, Team, Collection } from "@server/models";
import { authorize } from "@server/policies";
import { presentShare, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { assertUuid, assertSort, assertPresent } from "@server/validation";
import pagination from "./middlewares/pagination";
const router = new Router();
router.post("shares.info", auth(), async (ctx: APIContext) => {
const { id, documentId } = ctx.request.body;
assertPresent(id || documentId, "id or documentId is required");
if (id) {
assertUuid(id, "id is must be a uuid");
}
if (documentId) {
assertUuid(documentId, "documentId is must be a uuid");
}
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,
},
},
});
// 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 = await document.$get("collection");
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);
}
}
if (!shares.length) {
ctx.response.status = 204;
return;
}
ctx.body = {
data: {
shares: shares.map((share) => presentShare(share, user.isAdmin)),
},
policies: presentPolicies(user, shares),
};
});
router.post("shares.list", auth(), pagination(), async (ctx: APIContext) => {
let { direction } = ctx.request.body;
const { sort = "updatedAt" } = ctx.request.body;
if (direction !== "ASC") {
direction = "DESC";
}
assertSort(sort, Share);
const { user } = ctx.state.auth;
const where: WhereOptions<Share> = {
teamId: user.teamId,
userId: user.id,
published: true,
revokedAt: {
[Op.is]: null,
},
};
if (user.isAdmin) {
delete where.userId;
}
const collectionIds = await user.collectionIds();
const [shares, total] = await Promise.all([
Share.findAll({
where,
order: [[sort, direction]],
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Share.count({ where }),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: shares.map((share) => presentShare(share, user.isAdmin)),
policies: presentPolicies(user, shares),
};
});
router.post("shares.update", auth(), async (ctx: APIContext) => {
const { id, includeChildDocuments, published, urlId } = ctx.request.body;
assertUuid(id, "id is required");
const { user } = ctx.state.auth;
const team = await Team.findByPk(user.teamId);
authorize(user, "share", team);
// fetch the share with document and collection.
const share = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findByPk(id);
authorize(user, "update", share);
if (published !== undefined) {
share.published = published;
// Reset nested document sharing when unpublishing a share link. So that
// If it's ever re-published this doesn't immediately share nested docs
// without forewarning the user
if (!published) {
share.includeChildDocuments = false;
}
}
if (includeChildDocuments !== undefined) {
share.includeChildDocuments = includeChildDocuments;
}
if (!isUndefined(urlId)) {
share.urlId = urlId;
}
await share.save();
await Event.create({
name: "shares.update",
documentId: share.documentId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: {
published,
},
ip: ctx.request.ip,
});
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
});
router.post("shares.create", auth(), async (ctx: APIContext) => {
const { documentId } = ctx.request.body;
assertPresent(documentId, "documentId is required");
const { user } = ctx.state.auth;
const document = await Document.findByPk(documentId, {
userId: user.id,
});
// user could be creating the share link to share with team members
authorize(user, "read", document);
const team = await Team.findByPk(user.teamId);
const [share, isCreated] = await Share.findOrCreate({
where: {
documentId,
teamId: user.teamId,
revokedAt: null,
},
defaults: {
userId: user.id,
},
});
if (isCreated) {
await Event.create({
name: "shares.create",
documentId,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: {
name: document.title,
},
ip: ctx.request.ip,
});
}
if (team) {
share.team = team;
}
share.user = user;
share.document = document;
ctx.body = {
data: presentShare(share),
policies: presentPolicies(user, [share]),
};
});
router.post("shares.revoke", auth(), async (ctx: APIContext) => {
const { id } = ctx.request.body;
assertUuid(id, "id is required");
const { user } = ctx.state.auth;
const share = await Share.findByPk(id);
if (!share?.document) {
throw NotFoundError();
}
authorize(user, "revoke", share);
const { document } = share;
await share.revoke(user.id);
await Event.create({
name: "shares.revoke",
documentId: document.id,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: {
name: document.title,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -9,6 +9,15 @@ 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",
@@ -35,12 +44,3 @@ exports[`#shares.update should require authentication 1`] = `
"status": 401,
}
`;
exports[`should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1 @@
export { default } from "./shares";

View File

@@ -0,0 +1,82 @@
import { isEmpty } from "lodash";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { SHARE_URL_SLUG_REGEX, SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { Share } from "@server/models";
import BaseSchema from "../BaseSchema";
export const SharesInfoSchema = BaseSchema.extend({
body: z
.object({
id: z.string().uuid().optional(),
documentId: z
.string()
.optional()
.refine(
(val) => (val ? isUUID(val) || SLUG_URL_REGEX.test(val) : true),
{
message: "must be uuid or url slug",
}
),
})
.refine((body) => !(isEmpty(body.id) && isEmpty(body.documentId)), {
message: "id or documentId is required",
}),
});
export type SharesInfoReq = z.infer<typeof SharesInfoSchema>;
export const SharesListSchema = BaseSchema.extend({
body: z.object({
sort: z
.string()
.refine((val) => Object.keys(Share.getAttributes()).includes(val), {
message: `must be one of ${Object.keys(Share.getAttributes()).join(
", "
)}`,
})
.default("updatedAt"),
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
}),
});
export type SharesListReq = z.infer<typeof SharesListSchema>;
export const SharesUpdateSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
includeChildDocuments: z.boolean().optional(),
published: z.boolean().optional(),
urlId: z
.string()
.regex(SHARE_URL_SLUG_REGEX, {
message: "must contain only alphanumeric and dashes",
})
.nullish(),
}),
});
export type SharesUpdateReq = z.infer<typeof SharesUpdateSchema>;
export const SharesCreateSchema = BaseSchema.extend({
body: z.object({
documentId: z
.string()
.refine((val) => isUUID(val) || SLUG_URL_REGEX.test(val), {
message: "must be uuid or url slug",
}),
}),
});
export type SharesCreateReq = z.infer<typeof SharesCreateSchema>;
export const SharesRevokeSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
}),
});
export type SharesRevokeReq = z.infer<typeof SharesRevokeSchema>;

View File

@@ -1,5 +1,5 @@
import { CollectionPermission } from "@shared/types";
import { CollectionUser } from "@server/models";
import { CollectionUser, Share } from "@server/models";
import {
buildUser,
buildDocument,
@@ -13,6 +13,21 @@ import { seed, getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#shares.list", () => {
it("should fail with status 400 bad request when an invalid sort value is suppled", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.list", {
body: {
token: user.getJwtToken(),
sort: "foo",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
`sort: must be one of ${Object.keys(Share.getAttributes()).join(", ")}`
);
});
it("should only return shares created by user", async () => {
const { user, admin, document } = await seed();
await buildShare({
@@ -138,6 +153,31 @@ describe("#shares.list", () => {
});
describe("#shares.create", () => {
it("should fail with status 400 bad request when documentId is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.create", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: Required");
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.create", {
body: {
token: user.getJwtToken(),
documentId: "id",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: must be uuid or url slug");
});
it("should allow creating a share record for document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/shares.create", {
@@ -296,6 +336,118 @@ describe("#shares.create", () => {
});
describe("#shares.info", () => {
it("should fail with status 400 bad request when id and documentId both are missing", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("body: id or documentId is required");
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: "id",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("documentId: must be uuid or url slug");
});
it("should not find share by documentId in private collection", async () => {
const admin = await buildAdmin();
const collection = await buildCollection({
permission: null,
teamId: admin.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: admin.id,
teamId: admin.teamId,
});
const user = await buildUser({
teamId: admin.teamId,
});
await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const { user, document } = await seed();
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 { admin, document } = await seed();
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({
createdById: 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: {
token: user.getJwtToken(),
id: share.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.shares).toBeTruthy();
expect(body.data.shares).toHaveLength(1);
expect(body.data.shares[0].id).toEqual(share.id);
});
it("should allow reading share by documentId", async () => {
const { user, document } = await seed();
const share = await buildShare({
@@ -407,68 +559,6 @@ describe("#shares.info", () => {
});
});
it("should not find share by documentId in private collection", async () => {
const admin = await buildAdmin();
const collection = await buildCollection({
permission: null,
teamId: admin.teamId,
});
const document = await buildDocument({
collectionId: collection.id,
userId: admin.id,
teamId: admin.teamId,
});
const user = await buildUser({
teamId: admin.teamId,
});
await buildShare({
documentId: document.id,
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/shares.info", {
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const { user, document } = await seed();
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 { admin, document } = await seed();
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);
});
describe("#shares.update", () => {
it("should fail for invalid urlId", async () => {
const { user, document } = await seed();
@@ -486,10 +576,23 @@ describe("#shares.update", () => {
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
"Must be only alphanumeric and dashes (urlId)"
"urlId: must contain only alphanumeric and dashes"
);
});
it("should fail with status 400 bad request when id is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.update", {
body: {
token: user.getJwtToken(),
urlId: "url-id",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should update urlId", async () => {
const { user, document } = await seed();
const share = await buildShare({
@@ -631,6 +734,18 @@ describe("#shares.update", () => {
});
describe("#shares.revoke", () => {
it("should fail with status 400 bad request when id is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/shares.revoke", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should allow author to revoke a share", async () => {
const { user, document } = await seed();
const share = await buildShare({

View File

@@ -0,0 +1,312 @@
import Router from "koa-router";
import { isUndefined } from "lodash";
import { Op, WhereOptions } from "sequelize";
import { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Document, User, Event, Share, Team, Collection } from "@server/models";
import { authorize } from "@server/policies";
import { presentShare, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"shares.info",
auth(),
validate(T.SharesInfoSchema),
async (ctx: APIContext<T.SharesInfoReq>) => {
const { id, 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,
},
},
});
// 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 = await document.$get("collection");
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);
}
}
if (!shares.length) {
ctx.response.status = 204;
return;
}
ctx.body = {
data: {
shares: shares.map((share) => presentShare(share, user.isAdmin)),
},
policies: presentPolicies(user, shares),
};
}
);
router.post(
"shares.list",
auth(),
pagination(),
validate(T.SharesListSchema),
async (ctx: APIContext<T.SharesListReq>) => {
const { sort, direction } = ctx.input.body;
const { user } = ctx.state.auth;
const where: WhereOptions<Share> = {
teamId: user.teamId,
userId: user.id,
published: true,
revokedAt: {
[Op.is]: null,
},
};
if (user.isAdmin) {
delete where.userId;
}
const collectionIds = await user.collectionIds();
const [shares, total] = await Promise.all([
Share.findAll({
where,
order: [[sort, direction]],
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Share.count({ where }),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: shares.map((share) => presentShare(share, user.isAdmin)),
policies: presentPolicies(user, shares),
};
}
);
router.post(
"shares.update",
auth(),
validate(T.SharesUpdateSchema),
async (ctx: APIContext<T.SharesUpdateReq>) => {
const { id, includeChildDocuments, published, urlId } = ctx.input.body;
const { user } = ctx.state.auth;
const team = await Team.findByPk(user.teamId);
authorize(user, "share", team);
// fetch the share with document and collection.
const share = await Share.scope({
method: ["withCollectionPermissions", user.id],
}).findByPk(id);
authorize(user, "update", share);
if (published !== undefined) {
share.published = published;
// Reset nested document sharing when unpublishing a share link. So that
// If it's ever re-published this doesn't immediately share nested docs
// without forewarning the user
if (!published) {
share.includeChildDocuments = false;
}
}
if (includeChildDocuments !== undefined) {
share.includeChildDocuments = includeChildDocuments;
}
if (!isUndefined(urlId)) {
share.urlId = urlId;
}
await share.save();
await Event.create({
name: "shares.update",
documentId: share.documentId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: {
published,
},
ip: ctx.request.ip,
});
ctx.body = {
data: presentShare(share, user.isAdmin),
policies: presentPolicies(user, [share]),
};
}
);
router.post(
"shares.create",
auth(),
validate(T.SharesCreateSchema),
async (ctx: APIContext<T.SharesCreateReq>) => {
const { documentId } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(documentId, {
userId: user.id,
});
// user could be creating the share link to share with team members
authorize(user, "read", document);
const team = await Team.findByPk(user.teamId);
const [share, isCreated] = await Share.findOrCreate({
where: {
documentId,
teamId: user.teamId,
revokedAt: null,
},
defaults: {
userId: user.id,
},
});
if (isCreated) {
await Event.create({
name: "shares.create",
documentId,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: {
name: document.title,
},
ip: ctx.request.ip,
});
}
if (team) {
share.team = team;
}
share.user = user;
share.document = document;
ctx.body = {
data: presentShare(share),
policies: presentPolicies(user, [share]),
};
}
);
router.post(
"shares.revoke",
auth(),
validate(T.SharesRevokeSchema),
async (ctx: APIContext<T.SharesRevokeReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const share = await Share.findByPk(id);
if (!share?.document) {
throw NotFoundError();
}
authorize(user, "revoke", share);
const { document } = share;
await share.revoke(user.id);
await Event.create({
name: "shares.revoke",
documentId: document.id,
collectionId: document.collectionId,
modelId: share.id,
teamId: user.teamId,
actorId: user.id,
data: {
name: document.title,
},
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
}
);
export default router;