diff --git a/server/routes/api/shares.ts b/server/routes/api/shares.ts deleted file mode 100644 index c2e4b6495..000000000 --- a/server/routes/api/shares.ts +++ /dev/null @@ -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 = { - 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; diff --git a/server/routes/api/__snapshots__/shares.test.ts.snap b/server/routes/api/shares/__snapshots__/shares.test.ts.snap similarity index 93% rename from server/routes/api/__snapshots__/shares.test.ts.snap rename to server/routes/api/shares/__snapshots__/shares.test.ts.snap index 1163165a7..dcae0f250 100644 --- a/server/routes/api/__snapshots__/shares.test.ts.snap +++ b/server/routes/api/shares/__snapshots__/shares.test.ts.snap @@ -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, -} -`; diff --git a/server/routes/api/shares/index.ts b/server/routes/api/shares/index.ts new file mode 100644 index 000000000..edabb8e58 --- /dev/null +++ b/server/routes/api/shares/index.ts @@ -0,0 +1 @@ +export { default } from "./shares"; diff --git a/server/routes/api/shares/schema.ts b/server/routes/api/shares/schema.ts new file mode 100644 index 000000000..a865d9b4f --- /dev/null +++ b/server/routes/api/shares/schema.ts @@ -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; + +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; + +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; + +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; + +export const SharesRevokeSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type SharesRevokeReq = z.infer; diff --git a/server/routes/api/shares.test.ts b/server/routes/api/shares/shares.test.ts similarity index 78% rename from server/routes/api/shares.test.ts rename to server/routes/api/shares/shares.test.ts index 07a793026..204897671 100644 --- a/server/routes/api/shares.test.ts +++ b/server/routes/api/shares/shares.test.ts @@ -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({ diff --git a/server/routes/api/shares/shares.ts b/server/routes/api/shares/shares.ts new file mode 100644 index 000000000..ba7df7816 --- /dev/null +++ b/server/routes/api/shares/shares.ts @@ -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) => { + 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) => { + const { sort, direction } = ctx.input.body; + const { user } = ctx.state.auth; + const where: WhereOptions = { + 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) => { + 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) => { + 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) => { + 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;