diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index 5bef8d8f7758..b92be2181098 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -10,6 +10,7 @@ import Scrollable from "~/components/Scrollable"; import Text from "~/components/Text"; import { inviteUser } from "~/actions/definitions/users"; import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import OrganizationMenu from "~/menus/OrganizationMenu"; @@ -34,12 +35,15 @@ function AppSidebar() { const { t } = useTranslation(); const { documents } = useStores(); const team = useCurrentTeam(); + const user = useCurrentUser(); const can = usePolicy(team.id); React.useEffect(() => { - documents.fetchDrafts(); - documents.fetchTemplates(); - }, [documents]); + if (!user.isViewer) { + documents.fetchDrafts(); + documents.fetchTemplates(); + } + }, [documents, user.isViewer]); const [dndArea, setDndArea] = React.useState(); const handleSidebarRef = React.useCallback((node) => setDndArea(node), []); diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 9f9533cbdbec..8bce9bb62838 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react"; import * as React from "react"; import { Switch, Redirect, RouteComponentProps } from "react-router-dom"; import Archive from "~/scenes/Archive"; @@ -11,6 +12,8 @@ import CenteredContent from "~/components/CenteredContent"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import Route from "~/components/ProfiledRoute"; import SocketProvider from "~/components/SocketProvider"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import usePolicy from "~/hooks/usePolicy"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; const SettingsRoutes = React.lazy( @@ -59,7 +62,10 @@ const RedirectDocument = ({ /> ); -export default function AuthenticatedRoutes() { +function AuthenticatedRoutes() { + const team = useCurrentTeam(); + const can = usePolicy(team.id); + return ( @@ -71,14 +77,24 @@ export default function AuthenticatedRoutes() { } > + {can.createDocument && ( + + )} + {can.createDocument && ( + + )} + {can.createDocument && ( + + )} + {can.createDocument && ( + + )} + {can.createDocument && ( + + )} - - - - - @@ -103,3 +119,5 @@ export default function AuthenticatedRoutes() { ); } + +export default observer(AuthenticatedRoutes); diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index c998d5395604..809a56cb7fe3 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -1,15 +1,28 @@ import { Next } from "koa"; +import Logger from "@server/logging/Logger"; import tracer, { APM } from "@server/logging/tracing"; import { User, Team, ApiKey } from "@server/models"; import { getUserForJWT } from "@server/utils/jwt"; -import { AuthenticationError, UserSuspendedError } from "../errors"; -import { ContextWithState } from "../types"; - -export default function auth( - options: { - required?: boolean; - } = {} -) { +import { + AuthenticationError, + AuthorizationError, + UserSuspendedError, +} from "../errors"; +import { ContextWithState, AuthenticationTypes } from "../types"; + +type AuthenticationOptions = { + /* An admin user role is required to access the route */ + admin?: boolean; + /* A member or admin user role is required to access the route */ + member?: boolean; + /** + * Authentication is parsed, but optional. Note that if a token is provided + * in the request it must be valid or the requst will be rejected. + */ + optional?: boolean; +}; + +export default function auth(options: AuthenticationOptions = {}) { return async function authMiddleware(ctx: ContextWithState, next: Next) { let token; const authorizationHeader = ctx.request.get("authorization"); @@ -29,8 +42,11 @@ export default function auth( `Bad Authorization header format. Format is "Authorization: Bearer "` ); } - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - } else if (ctx.body && ctx.body.token) { + } else if ( + ctx.body && + typeof ctx.body === "object" && + "token" in ctx.body + ) { // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. token = ctx.body.token; } else if (ctx.request.query.token) { @@ -39,15 +55,15 @@ export default function auth( token = ctx.cookies.get("accessToken"); } - if (!token && options.required !== false) { + if (!token && options.optional !== true) { throw AuthenticationError("Authentication required"); } - let user; + let user: User | null | undefined; if (token) { if (String(token).match(/^[\w]{38}$/)) { - ctx.state.authType = "api"; + ctx.state.authType = AuthenticationTypes.API; let apiKey; try { @@ -78,7 +94,7 @@ export default function auth( throw AuthenticationError("Invalid API key"); } } else { - ctx.state.authType = "app"; + ctx.state.authType = AuthenticationTypes.APP; user = await getUserForJWT(String(token)); } @@ -94,8 +110,23 @@ export default function auth( }); } + if (options.admin) { + if (!user.isAdmin) { + throw AuthorizationError("Admin role required"); + } + } + + if (options.member) { + if (user.isViewer) { + throw AuthorizationError("Member role required"); + } + } + // not awaiting the promise here so that the request is not blocked - user.updateActiveAt(ctx.request.ip); + user.updateActiveAt(ctx.request.ip).catch((err) => { + Logger.error("Failed to update user activeAt", err); + }); + ctx.state.token = String(token); ctx.state.user = user; diff --git a/server/models/User.ts b/server/models/User.ts index b6f450f1b327..87e446083c90 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -276,7 +276,7 @@ class User extends ParanoidModel { .map((c) => c.id); }; - updateActiveAt = (ip: string, force = false) => { + updateActiveAt = async (ip: string, force = false) => { const fiveMinutesAgo = subMinutes(new Date(), 5); // ensure this is updated only every few minutes otherwise diff --git a/server/routes/api/apiKeys.ts b/server/routes/api/apiKeys.ts index 33b16b02d8cd..fc5e33104631 100644 --- a/server/routes/api/apiKeys.ts +++ b/server/routes/api/apiKeys.ts @@ -8,7 +8,7 @@ import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("apiKeys.create", auth(), async (ctx) => { +router.post("apiKeys.create", auth({ member: true }), async (ctx) => { const { name } = ctx.body; assertPresent(name, "name is required"); const { user } = ctx.state; @@ -35,24 +35,29 @@ router.post("apiKeys.create", auth(), async (ctx) => { }; }); -router.post("apiKeys.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; - const keys = await ApiKey.findAll({ - where: { - userId: user.id, - }, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); +router.post( + "apiKeys.list", + auth({ member: true }), + pagination(), + async (ctx) => { + const { user } = ctx.state; + const keys = await ApiKey.findAll({ + where: { + userId: user.id, + }, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); - ctx.body = { - pagination: ctx.state.pagination, - data: keys.map(presentApiKey), - }; -}); + ctx.body = { + pagination: ctx.state.pagination, + data: keys.map(presentApiKey), + }; + } +); -router.post("apiKeys.delete", auth(), async (ctx) => { +router.post("apiKeys.delete", auth({ member: true }), async (ctx) => { const { id } = ctx.body; assertUuid(id, "id is required"); const { user } = ctx.state; diff --git a/server/routes/api/documents.test.ts b/server/routes/api/documents.test.ts index 28efc5c0ca9e..54c0fe45c7b7 100644 --- a/server/routes/api/documents.test.ts +++ b/server/routes/api/documents.test.ts @@ -15,6 +15,7 @@ import { buildCollection, buildUser, buildDocument, + buildViewer, } from "@server/test/factories"; import { flushdb, seed } from "@server/test/support"; @@ -1432,12 +1433,76 @@ describe("#documents.archived", () => { expect(body.data.length).toEqual(0); }); + it("should require member", async () => { + const viewer = await buildViewer(); + const res = await server.post("/api/documents.archived", { + body: { + token: viewer.getJwtToken(), + }, + }); + expect(res.status).toEqual(403); + }); + it("should require authentication", async () => { const res = await server.post("/api/documents.archived"); expect(res.status).toEqual(401); }); }); +describe("#documents.deleted", () => { + it("should return deleted documents", async () => { + const { user } = await seed(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + await document.delete(user.id); + const res = await server.post("/api/documents.deleted", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + }); + + it("should not return documents in private collections not a member of", async () => { + const { user } = await seed(); + const collection = await buildCollection({ + permission: null, + }); + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + }); + await document.delete(user.id); + const res = await server.post("/api/documents.deleted", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it("should require member", async () => { + const viewer = await buildViewer(); + const res = await server.post("/api/documents.deleted", { + body: { + token: viewer.getJwtToken(), + }, + }); + expect(res.status).toEqual(403); + }); + + it("should require authentication", async () => { + const res = await server.post("/api/documents.deleted"); + expect(res.status).toEqual(401); + }); +}); + describe("#documents.viewed", () => { it("should return empty result if no views", async () => { const { user } = await seed(); diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts index cc8469a2662e..cb7abd660675 100644 --- a/server/routes/api/documents.ts +++ b/server/routes/api/documents.ts @@ -162,104 +162,117 @@ router.post("documents.list", auth(), pagination(), async (ctx) => { }; }); -router.post("documents.archived", auth(), pagination(), async (ctx) => { - const { sort = "updatedAt" } = ctx.body; +router.post( + "documents.archived", + auth({ member: true }), + pagination(), + async (ctx) => { + const { sort = "updatedAt" } = ctx.body; - assertSort(sort, Document); - let direction = ctx.body.direction; - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - const collectionIds = await user.collectionIds(); - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const viewScope: Readonly = { - method: ["withViews", user.id], - }; - const documents = await Document.scope([ - "defaultScope", - collectionScope, - viewScope, - ]).findAll({ - where: { - teamId: user.teamId, - collectionId: collectionIds, - archivedAt: { - [Op.ne]: null, + assertSort(sort, Document); + let direction = ctx.body.direction; + if (direction !== "ASC") { + direction = "DESC"; + } + const { user } = ctx.state; + const collectionIds = await user.collectionIds(); + const collectionScope: Readonly = { + method: ["withCollectionPermissions", user.id], + }; + const viewScope: Readonly = { + method: ["withViews", user.id], + }; + const documents = await Document.scope([ + "defaultScope", + collectionScope, + viewScope, + ]).findAll({ + where: { + teamId: user.teamId, + collectionId: collectionIds, + archivedAt: { + [Op.ne]: null, + }, }, - }, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + const policies = presentPolicies(user, documents); - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; + } +); -router.post("documents.deleted", auth(), pagination(), async (ctx) => { - const { sort = "deletedAt" } = ctx.body; +router.post( + "documents.deleted", + auth({ member: true }), + pagination(), + async (ctx) => { + const { sort = "deletedAt" } = ctx.body; - assertSort(sort, Document); - let direction = ctx.body.direction; - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; - const collectionIds = await user.collectionIds({ - paranoid: false, - }); - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const viewScope: Readonly = { - method: ["withViews", user.id], - }; - const documents = await Document.scope([collectionScope, viewScope]).findAll({ - where: { - teamId: user.teamId, - collectionId: collectionIds, - deletedAt: { - [Op.ne]: null, - }, - }, - include: [ - { - model: User, - as: "createdBy", - paranoid: false, - }, - { - model: User, - as: "updatedBy", - paranoid: false, + assertSort(sort, Document); + let direction = ctx.body.direction; + if (direction !== "ASC") { + direction = "DESC"; + } + const { user } = ctx.state; + const collectionIds = await user.collectionIds({ + paranoid: false, + }); + const collectionScope: Readonly = { + method: ["withCollectionPermissions", user.id], + }; + const viewScope: Readonly = { + method: ["withViews", user.id], + }; + const documents = await Document.scope([ + collectionScope, + viewScope, + ]).findAll({ + where: { + teamId: user.teamId, + collectionId: collectionIds, + deletedAt: { + [Op.ne]: null, + }, }, - ], - paranoid: false, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); + include: [ + { + model: User, + as: "createdBy", + paranoid: false, + }, + { + model: User, + as: "updatedBy", + paranoid: false, + }, + ], + paranoid: false, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + const policies = presentPolicies(user, documents); - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; + } +); router.post("documents.viewed", auth(), pagination(), async (ctx) => { let { direction } = ctx.body; @@ -314,76 +327,81 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => { }; }); -router.post("documents.drafts", auth(), pagination(), async (ctx) => { - let { direction } = ctx.body; - const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body; +router.post( + "documents.drafts", + auth({ member: true }), + pagination(), + async (ctx) => { + let { direction } = ctx.body; + const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body; - assertSort(sort, Document); - if (direction !== "ASC") { - direction = "DESC"; - } - const { user } = ctx.state; + assertSort(sort, Document); + if (direction !== "ASC") { + direction = "DESC"; + } + const { user } = ctx.state; - if (collectionId) { - assertUuid(collectionId, "collectionId must be a UUID"); - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "read", collection); - } + if (collectionId) { + assertUuid(collectionId, "collectionId must be a UUID"); + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "read", collection); + } - const collectionIds = collectionId - ? [collectionId] - : await user.collectionIds(); - const where: WhereOptions = { - createdById: user.id, - collectionId: collectionIds, - publishedAt: { - [Op.is]: null, - }, - }; + const collectionIds = collectionId + ? [collectionId] + : await user.collectionIds(); + const where: WhereOptions = { + createdById: user.id, + collectionId: collectionIds, + publishedAt: { + [Op.is]: null, + }, + }; - if (dateFilter) { - assertIn( - dateFilter, - ["day", "week", "month", "year"], - "dateFilter must be one of day,week,month,year" + if (dateFilter) { + assertIn( + dateFilter, + ["day", "week", "month", "year"], + "dateFilter must be one of day,week,month,year" + ); + where.updatedAt = { + [Op.gte]: subtractDate(new Date(), dateFilter), + }; + } else { + delete where.updatedAt; + } + + const collectionScope: Readonly = { + method: ["withCollectionPermissions", user.id], + }; + const documents = await Document.scope([ + "defaultScope", + collectionScope, + ]).findAll({ + where, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) ); - where.updatedAt = { - [Op.gte]: subtractDate(new Date(), dateFilter), + const policies = presentPolicies(user, documents); + + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, }; - } else { - delete where.updatedAt; } - - const collectionScope: Readonly = { - method: ["withCollectionPermissions", user.id], - }; - const documents = await Document.scope([ - "defaultScope", - collectionScope, - ]).findAll({ - where, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); +); router.post( "documents.info", auth({ - required: false, + optional: true, }), async (ctx) => { const { id, shareId, apiVersion } = ctx.body; @@ -421,7 +439,7 @@ router.post( router.post( "documents.export", auth({ - required: false, + optional: true, }), async (ctx) => { const { id, shareId } = ctx.body; @@ -438,7 +456,7 @@ router.post( } ); -router.post("documents.restore", auth(), async (ctx) => { +router.post("documents.restore", auth({ member: true }), async (ctx) => { const { id, collectionId, revisionId } = ctx.body; assertPresent(id, "id is required"); const { user } = ctx.state; @@ -588,7 +606,7 @@ router.post("documents.search_titles", auth(), pagination(), async (ctx) => { router.post( "documents.search", auth({ - required: false, + optional: true, }), pagination(), async (ctx) => { @@ -782,7 +800,7 @@ router.post("documents.unstar", auth(), async (ctx) => { }; }); -router.post("documents.templatize", auth(), async (ctx) => { +router.post("documents.templatize", auth({ member: true }), async (ctx) => { const { id } = ctx.body; assertPresent(id, "id is required"); const { user } = ctx.state; @@ -829,7 +847,7 @@ router.post("documents.templatize", auth(), async (ctx) => { }; }); -router.post("documents.update", auth(), async (ctx) => { +router.post("documents.update", auth({ member: true }), async (ctx) => { const { id, title, @@ -889,7 +907,7 @@ router.post("documents.update", auth(), async (ctx) => { }; }); -router.post("documents.move", auth(), async (ctx) => { +router.post("documents.move", auth({ member: true }), async (ctx) => { const { id, collectionId, parentDocumentId, index } = ctx.body; assertUuid(id, "id must be a uuid"); assertUuid(collectionId, "collectionId must be a uuid"); @@ -955,7 +973,7 @@ router.post("documents.move", auth(), async (ctx) => { }; }); -router.post("documents.archive", auth(), async (ctx) => { +router.post("documents.archive", auth({ member: true }), async (ctx) => { const { id } = ctx.body; assertPresent(id, "id is required"); const { user } = ctx.state; @@ -984,7 +1002,7 @@ router.post("documents.archive", auth(), async (ctx) => { }; }); -router.post("documents.delete", auth(), async (ctx) => { +router.post("documents.delete", auth({ member: true }), async (ctx) => { const { id, permanent } = ctx.body; assertPresent(id, "id is required"); const { user } = ctx.state; @@ -1045,7 +1063,7 @@ router.post("documents.delete", auth(), async (ctx) => { }; }); -router.post("documents.unpublish", auth(), async (ctx) => { +router.post("documents.unpublish", auth({ member: true }), async (ctx) => { const { id } = ctx.body; assertPresent(id, "id is required"); const { user } = ctx.state; @@ -1079,7 +1097,7 @@ router.post("documents.unpublish", auth(), async (ctx) => { }; }); -router.post("documents.import", auth(), async (ctx) => { +router.post("documents.import", auth({ member: true }), async (ctx) => { const { publish, collectionId, parentDocumentId, index } = ctx.body; if (!ctx.is("multipart/form-data")) { @@ -1162,7 +1180,7 @@ router.post("documents.import", auth(), async (ctx) => { }); }); -router.post("documents.create", auth(), async (ctx) => { +router.post("documents.create", auth({ member: true }), async (ctx) => { const { title = "", text = "", diff --git a/server/routes/api/fileOperations.ts b/server/routes/api/fileOperations.ts index df10d8ab06f9..b04f6a762a50 100644 --- a/server/routes/api/fileOperations.ts +++ b/server/routes/api/fileOperations.ts @@ -13,7 +13,7 @@ import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("fileOperations.info", auth(), async (ctx) => { +router.post("fileOperations.info", auth({ admin: true }), async (ctx) => { const { id } = ctx.body; assertUuid(id, "id is required"); const { user } = ctx.state; @@ -28,42 +28,47 @@ router.post("fileOperations.info", auth(), async (ctx) => { }; }); -router.post("fileOperations.list", auth(), pagination(), async (ctx) => { - let { direction } = ctx.body; - const { sort = "createdAt", type } = ctx.body; - assertIn(type, Object.values(FileOperationType)); - assertSort(sort, FileOperation); - - if (direction !== "ASC") { - direction = "DESC"; +router.post( + "fileOperations.list", + auth({ admin: true }), + pagination(), + async (ctx) => { + let { direction } = ctx.body; + const { sort = "createdAt", type } = ctx.body; + assertIn(type, Object.values(FileOperationType)); + assertSort(sort, FileOperation); + + if (direction !== "ASC") { + direction = "DESC"; + } + const { user } = ctx.state; + const where: WhereOptions = { + teamId: user.teamId, + type, + }; + const team = await Team.findByPk(user.teamId); + authorize(user, "manage", team); + + const [exports, total] = await Promise.all([ + await FileOperation.findAll({ + where, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + await FileOperation.count({ + where, + }), + ]); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: exports.map(presentFileOperation), + }; } - const { user } = ctx.state; - const where: WhereOptions = { - teamId: user.teamId, - type, - }; - const team = await Team.findByPk(user.teamId); - authorize(user, "manage", team); - - const [exports, total] = await Promise.all([ - await FileOperation.findAll({ - where, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }), - await FileOperation.count({ - where, - }), - ]); - - ctx.body = { - pagination: { ...ctx.state.pagination, total }, - data: exports.map(presentFileOperation), - }; -}); +); -router.post("fileOperations.redirect", auth(), async (ctx) => { +router.post("fileOperations.redirect", auth({ admin: true }), async (ctx) => { const { id } = ctx.body; assertUuid(id, "id is required"); @@ -81,7 +86,7 @@ router.post("fileOperations.redirect", auth(), async (ctx) => { ctx.redirect(accessUrl); }); -router.post("fileOperations.delete", auth(), async (ctx) => { +router.post("fileOperations.delete", auth({ admin: true }), async (ctx) => { const { id } = ctx.body; assertUuid(id, "id is required"); diff --git a/server/routes/api/webhookSubscriptions.ts b/server/routes/api/webhookSubscriptions.ts index 085031a9e728..71753935e94a 100644 --- a/server/routes/api/webhookSubscriptions.ts +++ b/server/routes/api/webhookSubscriptions.ts @@ -11,127 +11,144 @@ import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("webhookSubscriptions.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; - authorize(user, "listWebhookSubscription", user.team); - const webhooks = await WebhookSubscription.findAll({ - where: { - teamId: user.teamId, - }, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - - ctx.body = { - pagination: ctx.state.pagination, - data: webhooks.map(presentWebhookSubscription), - }; -}); - -router.post("webhookSubscriptions.create", auth(), async (ctx) => { - const { user } = ctx.state; - authorize(user, "createWebhookSubscription", user.team); - - const { name, url } = ctx.request.body; - const events: string[] = compact(ctx.request.body.events); - assertPresent(name, "name is required"); - assertPresent(url, "url is required"); - assertArray(events, "events is required"); - if (events.length === 0) { - throw ValidationError("events are required"); +router.post( + "webhookSubscriptions.list", + auth({ admin: true }), + pagination(), + async (ctx) => { + const { user } = ctx.state; + authorize(user, "listWebhookSubscription", user.team); + const webhooks = await WebhookSubscription.findAll({ + where: { + teamId: user.teamId, + }, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: webhooks.map(presentWebhookSubscription), + }; } - - const webhookSubscription = await WebhookSubscription.create({ - name, - events, - createdById: user.id, - teamId: user.teamId, - url, - enabled: true, - }); - - const event: WebhookSubscriptionEvent = { - name: "webhook_subscriptions.create", - modelId: webhookSubscription.id, - teamId: user.teamId, - actorId: user.id, - data: { +); + +router.post( + "webhookSubscriptions.create", + auth({ admin: true }), + async (ctx) => { + const { user } = ctx.state; + authorize(user, "createWebhookSubscription", user.team); + + const { name, url } = ctx.request.body; + const events: string[] = compact(ctx.request.body.events); + assertPresent(name, "name is required"); + assertPresent(url, "url is required"); + assertArray(events, "events is required"); + if (events.length === 0) { + throw ValidationError("events are required"); + } + + const webhookSubscription = await WebhookSubscription.create({ name, - url, events, - }, - ip: ctx.request.ip, - }; - await Event.create(event); - - ctx.body = { - data: presentWebhookSubscription(webhookSubscription), - }; -}); - -router.post("webhookSubscriptions.delete", auth(), async (ctx) => { - const { id } = ctx.body; - assertUuid(id, "id is required"); - const { user } = ctx.state; - const webhookSubscription = await WebhookSubscription.findByPk(id); - - authorize(user, "delete", webhookSubscription); - - await webhookSubscription.destroy(); - - const event: WebhookSubscriptionEvent = { - name: "webhook_subscriptions.delete", - modelId: webhookSubscription.id, - teamId: user.teamId, - actorId: user.id, - data: { - name: webhookSubscription.name, - url: webhookSubscription.url, - events: webhookSubscription.events, - }, - ip: ctx.request.ip, - }; - await Event.create(event); -}); - -router.post("webhookSubscriptions.update", auth(), async (ctx) => { - const { id } = ctx.body; - assertUuid(id, "id is required"); - const { user } = ctx.state; - - const { name, url } = ctx.request.body; - const events: string[] = compact(ctx.request.body.events); - assertPresent(name, "name is required"); - assertPresent(url, "url is required"); - assertArray(events, "events is required"); - if (events.length === 0) { - throw ValidationError("events are required"); + createdById: user.id, + teamId: user.teamId, + url, + enabled: true, + }); + + const event: WebhookSubscriptionEvent = { + name: "webhook_subscriptions.create", + modelId: webhookSubscription.id, + teamId: user.teamId, + actorId: user.id, + data: { + name, + url, + events, + }, + ip: ctx.request.ip, + }; + await Event.create(event); + + ctx.body = { + data: presentWebhookSubscription(webhookSubscription), + }; } +); - const webhookSubscription = await WebhookSubscription.findByPk(id); - - authorize(user, "update", webhookSubscription); - - await webhookSubscription.update({ name, url, events, enabled: true }); - - const event: WebhookSubscriptionEvent = { - name: "webhook_subscriptions.update", - modelId: webhookSubscription.id, - teamId: user.teamId, - actorId: user.id, - data: { - name: webhookSubscription.name, - url: webhookSubscription.url, - events: webhookSubscription.events, - }, - ip: ctx.request.ip, - }; - await Event.create(event); - - ctx.body = { - data: presentWebhookSubscription(webhookSubscription), - }; -}); +router.post( + "webhookSubscriptions.delete", + auth({ admin: true }), + async (ctx) => { + const { id } = ctx.body; + assertUuid(id, "id is required"); + const { user } = ctx.state; + const webhookSubscription = await WebhookSubscription.findByPk(id); + + authorize(user, "delete", webhookSubscription); + + await webhookSubscription.destroy(); + + const event: WebhookSubscriptionEvent = { + name: "webhook_subscriptions.delete", + modelId: webhookSubscription.id, + teamId: user.teamId, + actorId: user.id, + data: { + name: webhookSubscription.name, + url: webhookSubscription.url, + events: webhookSubscription.events, + }, + ip: ctx.request.ip, + }; + await Event.create(event); + } +); + +router.post( + "webhookSubscriptions.update", + auth({ admin: true }), + async (ctx) => { + const { id } = ctx.body; + assertUuid(id, "id is required"); + const { user } = ctx.state; + + const { name, url } = ctx.request.body; + const events: string[] = compact(ctx.request.body.events); + assertPresent(name, "name is required"); + assertPresent(url, "url is required"); + assertArray(events, "events is required"); + if (events.length === 0) { + throw ValidationError("events are required"); + } + + const webhookSubscription = await WebhookSubscription.findByPk(id); + + authorize(user, "update", webhookSubscription); + + await webhookSubscription.update({ name, url, events, enabled: true }); + + const event: WebhookSubscriptionEvent = { + name: "webhook_subscriptions.update", + modelId: webhookSubscription.id, + teamId: user.teamId, + actorId: user.id, + data: { + name: webhookSubscription.name, + url: webhookSubscription.url, + events: webhookSubscription.events, + }, + ip: ctx.request.ip, + }; + await Event.create(event); + + ctx.body = { + data: presentWebhookSubscription(webhookSubscription), + }; + } +); export default router; diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts index 9810f9b64b37..f020edda7ce6 100644 --- a/server/routes/auth/providers/slack.ts +++ b/server/routes/auth/providers/slack.ts @@ -117,7 +117,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { router.get( "slack.commands", auth({ - required: false, + optional: true, }), async (ctx) => { const { code, state, error } = ctx.request.query; @@ -135,9 +135,11 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { if (!user) { if (state) { try { - const team = await Team.findByPk(state as string); + const team = await Team.findByPk(String(state), { + rejectOnEmpty: true, + }); return ctx.redirect( - `${team!.url}/auth${ctx.request.path}?${ctx.request.querystring}` + `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}` ); } catch (err) { return ctx.redirect( @@ -152,8 +154,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { } const endpoint = `${env.URL}/auth/slack.commands`; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message - const data = await Slack.oauthAccess(code, endpoint); + const data = await Slack.oauthAccess(String(code), endpoint); const authentication = await IntegrationAuthentication.create({ service: "slack", userId: user.id, @@ -178,7 +179,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { router.get( "slack.post", auth({ - required: false, + optional: true, }), async (ctx) => { const { code, error, state } = ctx.request.query; @@ -198,10 +199,17 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { // appropriate subdomain to complete the oauth flow if (!user) { try { - const collection = await Collection.findByPk(state as string); - const team = await Team.findByPk(collection!.teamId); + const collection = await Collection.findOne({ + where: { + id: String(state), + }, + rejectOnEmpty: true, + }); + const team = await Team.findByPk(collection.teamId, { + rejectOnEmpty: true, + }); return ctx.redirect( - `${team!.url}/auth${ctx.request.path}?${ctx.request.querystring}` + `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}` ); } catch (err) { return ctx.redirect( diff --git a/server/test/factories.ts b/server/test/factories.ts index d45edd37c66a..d9ff3cc3ce44 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -161,6 +161,10 @@ export async function buildAdmin(overrides: Partial = {}) { return buildUser({ ...overrides, isAdmin: true }); } +export async function buildViewer(overrides: Partial = {}) { + return buildUser({ ...overrides, isViewer: true }); +} + export async function buildInvite(overrides: Partial = {}) { if (!overrides.teamId) { const team = await buildTeam(); diff --git a/server/types.ts b/server/types.ts index c527b5d6caef..eb40f2f8cb58 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,11 +1,16 @@ import { Context } from "koa"; import { FileOperation, Team, User } from "./models"; +export enum AuthenticationTypes { + API = "api", + APP = "app", +} + export type ContextWithState = Context & { state: { user: User; token: string; - authType: "app" | "api"; + authType: AuthenticationTypes; }; };