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:
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
1
server/routes/api/shares/index.ts
Normal file
1
server/routes/api/shares/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./shares";
|
||||
82
server/routes/api/shares/schema.ts
Normal file
82
server/routes/api/shares/schema.ts
Normal 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>;
|
||||
@@ -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({
|
||||
312
server/routes/api/shares/shares.ts
Normal file
312
server/routes/api/shares/shares.ts
Normal 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;
|
||||
Reference in New Issue
Block a user