From f4461573ded7539d29de35edd6b1477fb451bac3 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Wed, 4 Jan 2023 23:51:44 +0530 Subject: [PATCH] Refactor to accommodate authentication, transaction and pagination states together (#4636) * fix: refactor to accommodate authentication, transaction and pagination together into ctx.state * feat: allow passing response type to APIContext --- server/logging/sentry.ts | 10 +- server/middlewares/authentication.test.ts | 8 +- server/middlewares/authentication.ts | 22 +- server/middlewares/transaction.ts | 11 +- server/routes/api/apiKeys.ts | 101 +++---- server/routes/api/attachments/attachments.ts | 11 +- server/routes/api/auth.ts | 60 ++-- server/routes/api/authenticationProviders.ts | 13 +- server/routes/api/collections.ts | 274 ++++++++++--------- server/routes/api/developer.ts | 68 ++--- server/routes/api/documents/documents.ts | 41 ++- server/routes/api/events/events.ts | 2 +- server/routes/api/fileOperations.ts | 68 +++-- server/routes/api/groups.ts | 126 +++++---- server/routes/api/hooks.ts | 7 +- server/routes/api/index.ts | 9 +- server/routes/api/integrations.ts | 198 ++++++++------ server/routes/api/middlewares/pagination.ts | 19 +- server/routes/api/notificationSettings.ts | 16 +- server/routes/api/pins.ts | 17 +- server/routes/api/revisions.ts | 13 +- server/routes/api/searches.ts | 9 +- server/routes/api/shares.ts | 21 +- server/routes/api/stars.ts | 17 +- server/routes/api/subscriptions.ts | 81 +++--- server/routes/api/team.ts | 9 +- server/routes/api/users.ts | 132 +++++---- server/routes/api/views.ts | 9 +- server/routes/api/webhookSubscriptions.ts | 18 +- server/routes/auth/index.ts | 7 +- server/types.ts | 31 ++- 31 files changed, 753 insertions(+), 675 deletions(-) diff --git a/server/logging/sentry.ts b/server/logging/sentry.ts index 9e2745a02..a89221d0d 100644 --- a/server/logging/sentry.ts +++ b/server/logging/sentry.ts @@ -1,6 +1,6 @@ import * as Sentry from "@sentry/node"; import env from "@server/env"; -import { ContextWithState } from "../types"; +import { AppContext } from "@server/types"; if (env.SENTRY_DSN) { Sentry.init({ @@ -29,7 +29,7 @@ if (env.SENTRY_DSN) { }); } -export function requestErrorHandler(error: any, ctx: ContextWithState) { +export function requestErrorHandler(error: any, ctx: AppContext) { // we don't need to report every time a request stops to the bug tracker if (error.code === "EPIPE" || error.code === "ECONNRESET") { console.warn("Connection error", { @@ -46,17 +46,17 @@ export function requestErrorHandler(error: any, ctx: ContextWithState) { scope.setTag("request_id", requestId as string); } - const authType = ctx.state?.authType ?? undefined; + const authType = ctx.state?.auth?.type ?? undefined; if (authType) { scope.setTag("auth_type", authType); } - const teamId = ctx.state?.user?.teamId ?? undefined; + const teamId = ctx.state?.auth?.user?.teamId ?? undefined; if (teamId) { scope.setTag("team_id", teamId); } - const userId = ctx.state?.user?.id ?? undefined; + const userId = ctx.state?.auth?.user?.id ?? undefined; if (userId) { scope.setUser({ id: userId, diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 7a29c10bd..2ade227d1 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -24,7 +24,7 @@ describe("Authentication middleware", () => { }, jest.fn() ); - expect(state.user.id).toEqual(user.id); + expect(state.auth.user.id).toEqual(user.id); }); it("should return error with invalid token", async () => { @@ -68,7 +68,7 @@ describe("Authentication middleware", () => { }, jest.fn() ); - expect(state.user.id).toEqual(user.id); + expect(state.auth.user.id).toEqual(user.id); }); it("should return error with invalid API key", async () => { const state = {} as DefaultState; @@ -133,7 +133,7 @@ describe("Authentication middleware", () => { }, jest.fn() ); - expect(state.user.id).toEqual(user.id); + expect(state.auth.user.id).toEqual(user.id); }); it("should allow passing auth token in body params", async () => { @@ -154,7 +154,7 @@ describe("Authentication middleware", () => { }, jest.fn() ); - expect(state.user.id).toEqual(user.id); + expect(state.auth.user.id).toEqual(user.id); }); it("should return an error for suspended users", async () => { diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index b0d577853..278d89008 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -5,13 +5,13 @@ import tracer, { getRootSpanFromRequestContext, } from "@server/logging/tracer"; import { User, Team, ApiKey } from "@server/models"; +import { AppContext, AuthenticationType } from "@server/types"; import { getUserForJWT } from "@server/utils/jwt"; import { AuthenticationError, AuthorizationError, UserSuspendedError, } from "../errors"; -import { ContextWithState, AuthenticationType } from "../types"; type AuthenticationOptions = { /* An admin user role is required to access the route */ @@ -26,7 +26,7 @@ type AuthenticationOptions = { }; export default function auth(options: AuthenticationOptions = {}) { - return async function authMiddleware(ctx: ContextWithState, next: Next) { + return async function authMiddleware(ctx: AppContext, next: Next) { let token; const authorizationHeader = ctx.request.get("authorization"); @@ -61,11 +61,12 @@ export default function auth(options: AuthenticationOptions = {}) { throw AuthenticationError("Authentication required"); } - let user: User | null | undefined; + let user: User | null; + let type: AuthenticationType; if (token) { if (ApiKey.match(String(token))) { - ctx.state.authType = AuthenticationType.API; + type = AuthenticationType.API; let apiKey; try { @@ -96,7 +97,7 @@ export default function auth(options: AuthenticationOptions = {}) { throw AuthenticationError("Invalid API key"); } } else { - ctx.state.authType = AuthenticationType.APP; + type = AuthenticationType.APP; user = await getUserForJWT(String(token)); } @@ -129,19 +130,24 @@ export default function auth(options: AuthenticationOptions = {}) { Logger.error("Failed to update user activeAt", err); }); - ctx.state.token = String(token); - ctx.state.user = user; + ctx.state.auth = { + user, + token: String(token), + type, + }; if (tracer) { addTags( { "request.userId": user.id, "request.teamId": user.teamId, - "request.authType": ctx.state.authType, + "request.authType": type, }, getRootSpanFromRequestContext(ctx) ); } + } else { + ctx.state.auth = {}; } return next(); diff --git a/server/middlewares/transaction.ts b/server/middlewares/transaction.ts index 9291b3e9d..58ca1b434 100644 --- a/server/middlewares/transaction.ts +++ b/server/middlewares/transaction.ts @@ -1,12 +1,7 @@ -import { Context, Next } from "koa"; +import { Next } from "koa"; import { Transaction } from "sequelize"; import { sequelize } from "@server/database/sequelize"; - -export type TransactionContext = Context & { - state: Context["state"] & { - transaction: Transaction; - }; -}; +import { AppContext } from "@server/types"; /** * Middleware that wraps a route in a database transaction, useful for mutations @@ -16,7 +11,7 @@ export type TransactionContext = Context & { * @returns The middleware function. */ export function transaction() { - return async function transactionMiddleware(ctx: Context, next: Next) { + return async function transactionMiddleware(ctx: AppContext, next: Next) { await sequelize.transaction(async (t: Transaction) => { ctx.state.transaction = t; return next(); diff --git a/server/routes/api/apiKeys.ts b/server/routes/api/apiKeys.ts index 31f4ffa5f..7b96dea0d 100644 --- a/server/routes/api/apiKeys.ts +++ b/server/routes/api/apiKeys.ts @@ -3,44 +3,49 @@ import auth from "@server/middlewares/authentication"; import { ApiKey, Event } from "@server/models"; import { authorize } from "@server/policies"; import { presentApiKey } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertUuid, assertPresent } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("apiKeys.create", auth({ member: true }), async (ctx) => { - const { name } = ctx.request.body; - assertPresent(name, "name is required"); - const { user } = ctx.state; +router.post( + "apiKeys.create", + auth({ member: true }), + async (ctx: APIContext) => { + const { name } = ctx.request.body; + assertPresent(name, "name is required"); + const { user } = ctx.state.auth; - authorize(user, "createApiKey", user.team); - const key = await ApiKey.create({ - name, - userId: user.id, - }); - - await Event.create({ - name: "api_keys.create", - modelId: key.id, - teamId: user.teamId, - actorId: user.id, - data: { + authorize(user, "createApiKey", user.team); + const key = await ApiKey.create({ name, - }, - ip: ctx.request.ip, - }); + userId: user.id, + }); - ctx.body = { - data: presentApiKey(key), - }; -}); + await Event.create({ + name: "api_keys.create", + modelId: key.id, + teamId: user.teamId, + actorId: user.id, + data: { + name, + }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: presentApiKey(key), + }; + } +); router.post( "apiKeys.list", auth({ member: true }), pagination(), - async (ctx) => { - const { user } = ctx.state; + async (ctx: APIContext) => { + const { user } = ctx.state.auth; const keys = await ApiKey.findAll({ where: { userId: user.id, @@ -57,28 +62,32 @@ router.post( } ); -router.post("apiKeys.delete", auth({ member: true }), async (ctx) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - const { user } = ctx.state; - const key = await ApiKey.findByPk(id); - authorize(user, "delete", key); +router.post( + "apiKeys.delete", + auth({ member: true }), + async (ctx: APIContext) => { + const { id } = ctx.request.body; + assertUuid(id, "id is required"); + const { user } = ctx.state.auth; + const key = await ApiKey.findByPk(id); + authorize(user, "delete", key); - await key.destroy(); - await Event.create({ - name: "api_keys.delete", - modelId: key.id, - teamId: user.teamId, - actorId: user.id, - data: { - name: key.name, - }, - ip: ctx.request.ip, - }); + await key.destroy(); + await Event.create({ + name: "api_keys.delete", + modelId: key.id, + teamId: user.teamId, + actorId: user.id, + data: { + name: key.name, + }, + ip: ctx.request.ip, + }); - ctx.body = { - success: true, - }; -}); + ctx.body = { + success: true, + }; + } +); export default router; diff --git a/server/routes/api/attachments/attachments.ts b/server/routes/api/attachments/attachments.ts index d74e083d3..9a4281abb 100644 --- a/server/routes/api/attachments/attachments.ts +++ b/server/routes/api/attachments/attachments.ts @@ -11,7 +11,7 @@ import { Attachment, Document, Event } from "@server/models"; import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; import { authorize } from "@server/policies"; import { presentAttachment } from "@server/presenters"; -import { APIContext, ContextWithState } from "@server/types"; +import { APIContext } from "@server/types"; import { getPresignedPost, publicS3Endpoint } from "@server/utils/s3"; import { assertIn, assertUuid } from "@server/validation"; import * as T from "./schema"; @@ -25,7 +25,8 @@ router.post( transaction(), async (ctx: APIContext) => { const { name, documentId, contentType, size, preset } = ctx.input; - const { user, transaction } = ctx.state; + const { auth, transaction } = ctx.state; + const { user } = auth; // All user types can upload an avatar so no additional authorization is needed. if (preset === AttachmentPreset.Avatar) { @@ -113,7 +114,7 @@ router.post( validate(T.AttachmentDeleteSchema), async (ctx: APIContext) => { const { id } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const attachment = await Attachment.findByPk(id, { rejectOnEmpty: true, }); @@ -140,11 +141,11 @@ router.post( } ); -const handleAttachmentsRedirect = async (ctx: ContextWithState) => { +const handleAttachmentsRedirect = async (ctx: APIContext) => { const id = ctx.request.body?.id ?? ctx.request.query?.id; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const attachment = await Attachment.findByPk(id, { rejectOnEmpty: true, }); diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index c6c9f2bdc..72e7ec0e4 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -4,10 +4,7 @@ import { TeamPreference } from "@shared/types"; import { parseDomain } from "@shared/utils/domains"; import env from "@server/env"; import auth from "@server/middlewares/authentication"; -import { - transaction, - TransactionContext, -} from "@server/middlewares/transaction"; +import { transaction } from "@server/middlewares/transaction"; import { Event, Team } from "@server/models"; import { presentUser, @@ -16,6 +13,7 @@ import { presentAvailableTeam, } from "@server/presenters"; import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask"; +import { APIContext } from "@server/types"; import { getSessionsInCookie } from "@server/utils/authentication"; import providers from "../auth/providers"; @@ -47,7 +45,7 @@ function filterProviders(team?: Team) { })); } -router.post("auth.config", async (ctx) => { +router.post("auth.config", async (ctx: APIContext) => { // If self hosted AND there is only one team then that team becomes the // brand for the knowledge base and it's guest signin option is used for the // root login page. @@ -124,8 +122,8 @@ router.post("auth.config", async (ctx) => { }; }); -router.post("auth.info", auth(), async (ctx) => { - const { user } = ctx.state; +router.post("auth.info", auth(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const sessions = getSessionsInCookie(ctx); const signedInTeamIds = Object.keys(sessions); @@ -163,34 +161,30 @@ router.post("auth.info", auth(), async (ctx) => { }; }); -router.post( - "auth.delete", - auth(), - transaction(), - async (ctx: TransactionContext) => { - const { user, transaction } = ctx.state; +router.post("auth.delete", auth(), transaction(), async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const { user } = auth; - await user.rotateJwtSecret({ transaction }); - await Event.create( - { - name: "users.signout", - actorId: user.id, - userId: user.id, - teamId: user.teamId, - data: { - name: user.name, - }, - ip: ctx.request.ip, + await user.rotateJwtSecret({ transaction }); + await Event.create( + { + name: "users.signout", + actorId: user.id, + userId: user.id, + teamId: user.teamId, + data: { + name: user.name, }, - { - transaction, - } - ); + ip: ctx.request.ip, + }, + { + transaction, + } + ); - ctx.body = { - success: true, - }; - } -); + ctx.body = { + success: true, + }; +}); export default router; diff --git a/server/routes/api/authenticationProviders.ts b/server/routes/api/authenticationProviders.ts index ae3548e96..62b51d9e5 100644 --- a/server/routes/api/authenticationProviders.ts +++ b/server/routes/api/authenticationProviders.ts @@ -7,6 +7,7 @@ import { presentAuthenticationProvider, presentPolicies, } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertUuid, assertPresent } from "@server/validation"; import allAuthenticationProviders from "../auth/providers"; @@ -15,11 +16,11 @@ const router = new Router(); router.post( "authenticationProviders.info", auth({ admin: true }), - async (ctx) => { + async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const authenticationProvider = await AuthenticationProvider.findByPk(id); authorize(user, "read", authenticationProvider); @@ -33,11 +34,11 @@ router.post( router.post( "authenticationProviders.update", auth({ admin: true }), - async (ctx) => { + async (ctx: APIContext) => { const { id, isEnabled } = ctx.request.body; assertUuid(id, "id is required"); assertPresent(isEnabled, "isEnabled is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const authenticationProvider = await sequelize.transaction( async (transaction) => { @@ -86,8 +87,8 @@ router.post( router.post( "authenticationProviders.list", auth({ admin: true }), - async (ctx) => { - const { user } = ctx.state; + async (ctx: APIContext) => { + const { user } = ctx.state.auth; authorize(user, "read", user.team); const teamAuthenticationProviders = (await user.team.$get( diff --git a/server/routes/api/collections.ts b/server/routes/api/collections.ts index 4299db254..006da0b51 100644 --- a/server/routes/api/collections.ts +++ b/server/routes/api/collections.ts @@ -16,10 +16,7 @@ import { sequelize } from "@server/database/sequelize"; import { AuthorizationError, ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; -import { - TransactionContext, - transaction, -} from "@server/middlewares/transaction"; +import { transaction } from "@server/middlewares/transaction"; import { Collection, CollectionUser, @@ -41,6 +38,7 @@ import { presentCollectionGroupMembership, presentFileOperation, } from "@server/presenters"; +import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { collectionIndexing } from "@server/utils/indexing"; import removeIndexCollision from "@server/utils/removeIndexCollision"; @@ -56,7 +54,7 @@ import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("collections.create", auth(), async (ctx) => { +router.post("collections.create", auth(), async (ctx: APIContext) => { const { name, color = randomElement(colorPalette), @@ -73,7 +71,7 @@ router.post("collections.create", auth(), async (ctx) => { assertHexColor(color, "Invalid hex value (please use format #FFFFFF)"); } - const { user } = ctx.state; + const { user } = ctx.state.auth; authorize(user, "createCollection", user.team); if (index) { @@ -134,10 +132,10 @@ router.post("collections.create", auth(), async (ctx) => { }; }); -router.post("collections.info", auth(), async (ctx) => { +router.post("collections.info", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertPresent(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const collection = await Collection.scope({ method: ["withMembership", user.id], }).findByPk(id); @@ -154,14 +152,14 @@ router.post( "collections.import", auth(), rateLimiter(RateLimiterStrategy.TenPerHour), - async (ctx) => { + async (ctx: APIContext) => { const { attachmentId, format = FileOperationFormat.MarkdownZip, } = ctx.request.body; assertUuid(attachmentId, "attachmentId is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; authorize(user, "importCollection", user.team); const attachment = await Attachment.findByPk(attachmentId); @@ -207,7 +205,7 @@ router.post( } ); -router.post("collections.add_group", auth(), async (ctx) => { +router.post("collections.add_group", auth(), async (ctx: APIContext) => { const { id, groupId, @@ -217,13 +215,15 @@ router.post("collections.add_group", auth(), async (ctx) => { assertUuid(groupId, "groupId is required"); assertCollectionPermission(permission); + const { user } = ctx.state.auth; + const collection = await Collection.scope({ - method: ["withMembership", ctx.state.user.id], + method: ["withMembership", user.id], }).findByPk(id); - authorize(ctx.state.user, "update", collection); + authorize(user, "update", collection); const group = await Group.findByPk(groupId); - authorize(ctx.state.user, "read", group); + authorize(user, "read", group); let membership = await CollectionGroup.findOne({ where: { @@ -237,7 +237,7 @@ router.post("collections.add_group", auth(), async (ctx) => { collectionId: id, groupId, permission, - createdById: ctx.state.user.id, + createdById: user.id, }); } else if (permission) { membership.permission = permission; @@ -248,7 +248,7 @@ router.post("collections.add_group", auth(), async (ctx) => { name: "collections.add_group", collectionId: collection.id, teamId: collection.teamId, - actorId: ctx.state.user.id, + actorId: user.id, modelId: groupId, data: { name: group.name, @@ -265,25 +265,27 @@ router.post("collections.add_group", auth(), async (ctx) => { }; }); -router.post("collections.remove_group", auth(), async (ctx) => { +router.post("collections.remove_group", auth(), async (ctx: APIContext) => { const { id, groupId } = ctx.request.body; assertUuid(id, "id is required"); assertUuid(groupId, "groupId is required"); + const { user } = ctx.state.auth; + const collection = await Collection.scope({ - method: ["withMembership", ctx.state.user.id], + method: ["withMembership", user.id], }).findByPk(id); - authorize(ctx.state.user, "update", collection); + authorize(user, "update", collection); const group = await Group.findByPk(groupId); - authorize(ctx.state.user, "read", group); + authorize(user, "read", group); await collection.$remove("group", group); await Event.create({ name: "collections.remove_group", collectionId: collection.id, teamId: collection.teamId, - actorId: ctx.state.user.id, + actorId: user.id, modelId: groupId, data: { name: group.name, @@ -300,10 +302,10 @@ router.post( "collections.group_memberships", auth(), pagination(), - async (ctx) => { + async (ctx: APIContext) => { const { id, query, permission } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const collection = await Collection.scope({ method: ["withMembership", user.id], @@ -365,19 +367,20 @@ router.post( "collections.add_user", auth(), transaction(), - async (ctx: TransactionContext) => { - const { transaction } = ctx.state; + async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const actor = auth.user; const { id, userId, permission } = ctx.request.body; assertUuid(id, "id is required"); assertUuid(userId, "userId is required"); const collection = await Collection.scope({ - method: ["withMembership", ctx.state.user.id], + method: ["withMembership", actor.id], }).findByPk(id); - authorize(ctx.state.user, "update", collection); + authorize(actor, "update", collection); const user = await User.findByPk(userId); - authorize(ctx.state.user, "read", user); + authorize(actor, "read", user); let membership = await CollectionUser.findOne({ where: { @@ -388,7 +391,7 @@ router.post( lock: transaction.LOCK.UPDATE, }); - if (userId === ctx.state.user.id) { + if (userId === actor.id) { throw AuthorizationError("You cannot add yourself to a collection"); } @@ -402,7 +405,7 @@ router.post( collectionId: id, userId, permission: permission || user.defaultCollectionPermission, - createdById: ctx.state.user.id, + createdById: actor.id, }, { transaction, @@ -419,7 +422,7 @@ router.post( userId, collectionId: collection.id, teamId: collection.teamId, - actorId: ctx.state.user.id, + actorId: actor.id, data: { name: user.name, }, @@ -443,19 +446,20 @@ router.post( "collections.remove_user", auth(), transaction(), - async (ctx: TransactionContext) => { - const { transaction } = ctx.state; + async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const actor = auth.user; const { id, userId } = ctx.request.body; assertUuid(id, "id is required"); assertUuid(userId, "userId is required"); const collection = await Collection.scope({ - method: ["withMembership", ctx.state.user.id], + method: ["withMembership", actor.id], }).findByPk(id); - authorize(ctx.state.user, "update", collection); + authorize(actor, "update", collection); const user = await User.findByPk(userId); - authorize(ctx.state.user, "read", user); + authorize(actor, "read", user); await collection.$remove("user", user, { transaction }); await Event.create( @@ -464,7 +468,7 @@ router.post( userId, collectionId: collection.id, teamId: collection.teamId, - actorId: ctx.state.user.id, + actorId: actor.id, data: { name: user.name, }, @@ -479,77 +483,82 @@ router.post( } ); -router.post("collections.memberships", auth(), pagination(), async (ctx) => { - const { id, query, permission } = ctx.request.body; - assertUuid(id, "id is required"); - const { user } = ctx.state; +router.post( + "collections.memberships", + auth(), + pagination(), + async (ctx: APIContext) => { + const { id, query, permission } = ctx.request.body; + assertUuid(id, "id is required"); + const { user } = ctx.state.auth; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(id); - authorize(user, "read", collection); + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(id); + authorize(user, "read", collection); - let where: WhereOptions = { - collectionId: id, - }; - let userWhere; + let where: WhereOptions = { + collectionId: id, + }; + let userWhere; - if (query) { - userWhere = { - name: { - [Op.iLike]: `%${query}%`, + if (query) { + userWhere = { + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + if (permission) { + assertCollectionPermission(permission); + where = { ...where, permission }; + } + + const options = { + where, + include: [ + { + model: User, + as: "user", + where: userWhere, + required: true, + }, + ], + }; + + const [total, memberships] = await Promise.all([ + CollectionUser.count(options), + CollectionUser.findAll({ + ...options, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + ]); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: { + memberships: memberships.map(presentMembership), + users: memberships.map((membership) => presentUser(membership.user)), }, }; } - - if (permission) { - assertCollectionPermission(permission); - where = { ...where, permission }; - } - - const options = { - where, - include: [ - { - model: User, - as: "user", - where: userWhere, - required: true, - }, - ], - }; - - const [total, memberships] = await Promise.all([ - CollectionUser.count(options), - CollectionUser.findAll({ - ...options, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }), - ]); - - ctx.body = { - pagination: { ...ctx.state.pagination, total }, - data: { - memberships: memberships.map(presentMembership), - users: memberships.map((membership) => presentUser(membership.user)), - }, - }; -}); +); router.post( "collections.export", auth(), rateLimiter(RateLimiterStrategy.TenPerHour), - async (ctx) => { + async (ctx: APIContext) => { const { id } = ctx.request.body; const { format = FileOperationFormat.MarkdownZip } = ctx.request.body; assertUuid(id, "id is required"); assertIn(format, Object.values(FileOperationFormat), "Invalid format"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId); authorize(user, "createExport", team); @@ -582,9 +591,9 @@ router.post( "collections.export_all", auth(), rateLimiter(RateLimiterStrategy.FivePerHour), - async (ctx) => { + async (ctx: APIContext) => { const { format = FileOperationFormat.MarkdownZip } = ctx.request.body; - const { user } = ctx.state; + const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId); authorize(user, "createExport", team); @@ -609,7 +618,7 @@ router.post( } ); -router.post("collections.update", auth(), async (ctx) => { +router.post("collections.update", auth(), async (ctx: APIContext) => { const { id, name, @@ -625,7 +634,7 @@ router.post("collections.update", auth(), async (ctx) => { assertHexColor(color, "Invalid hex value (please use format #FFFFFF)"); } - const { user } = ctx.state; + const { user } = ctx.state.auth; const collection = await Collection.scope({ method: ["withMembership", user.id], }).findByPk(id); @@ -736,43 +745,48 @@ router.post("collections.update", auth(), async (ctx) => { }; }); -router.post("collections.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; - const collectionIds = await user.collectionIds(); - const where: WhereOptions = { - teamId: user.teamId, - id: collectionIds, - }; - const collections = await Collection.scope({ - method: ["withMembership", user.id], - }).findAll({ - where, - order: [["updatedAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - - const nullIndex = collections.findIndex( - (collection) => collection.index === null - ); - - if (nullIndex !== -1) { - const indexedCollections = await collectionIndexing(user.teamId); - collections.forEach((collection) => { - collection.index = indexedCollections[collection.id]; +router.post( + "collections.list", + auth(), + pagination(), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + const collectionIds = await user.collectionIds(); + const where: WhereOptions = { + teamId: user.teamId, + id: collectionIds, + }; + const collections = await Collection.scope({ + method: ["withMembership", user.id], + }).findAll({ + where, + order: [["updatedAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, }); + + const nullIndex = collections.findIndex( + (collection) => collection.index === null + ); + + if (nullIndex !== -1) { + const indexedCollections = await collectionIndexing(user.teamId); + collections.forEach((collection) => { + collection.index = indexedCollections[collection.id]; + }); + } + + ctx.body = { + pagination: ctx.state.pagination, + data: collections.map(presentCollection), + policies: presentPolicies(user, collections), + }; } +); - ctx.body = { - pagination: ctx.state.pagination, - data: collections.map(presentCollection), - policies: presentPolicies(user, collections), - }; -}); - -router.post("collections.delete", auth(), async (ctx) => { +router.post("collections.delete", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; - const { user } = ctx.state; + const { user } = ctx.state.auth; assertUuid(id, "id is required"); const collection = await Collection.scope({ @@ -814,13 +828,13 @@ router.post("collections.delete", auth(), async (ctx) => { }; }); -router.post("collections.move", auth(), async (ctx) => { +router.post("collections.move", auth(), async (ctx: APIContext) => { const id = ctx.request.body.id; let index = ctx.request.body.index; assertPresent(index, "index is required"); assertIndexCharacters(index); assertUuid(id, "id must be a uuid"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const collection = await Collection.findByPk(id); authorize(user, "move", collection); diff --git a/server/routes/api/developer.ts b/server/routes/api/developer.ts index ebd5bfa0c..4c1ea0b5c 100644 --- a/server/routes/api/developer.ts +++ b/server/routes/api/developer.ts @@ -6,6 +6,7 @@ import env from "@server/env"; import Logger from "@server/logging/Logger"; import auth from "@server/middlewares/authentication"; import { presentUser } from "@server/presenters"; +import { APIContext } from "@server/types"; const router = new Router(); @@ -19,40 +20,45 @@ function dev() { }; } -router.post("developer.create_test_users", dev(), auth(), async (ctx) => { - const { count = 10 } = ctx.request.body; - const { user } = ctx.state; - const invites = Array(Math.min(count, 100)) - .fill(0) - .map(() => { - const rando = randomstring.generate(10); +router.post( + "developer.create_test_users", + dev(), + auth(), + async (ctx: APIContext) => { + const { count = 10 } = ctx.request.body; + const { user } = ctx.state.auth; + const invites = Array(Math.min(count, 100)) + .fill(0) + .map(() => { + const rando = randomstring.generate(10); - return { - email: `${rando}@example.com`, - name: `${rando.slice(0, 5)} Tester`, - role: "member", - } as Invite; + return { + email: `${rando}@example.com`, + name: `${rando.slice(0, 5)} Tester`, + role: "member", + } as Invite; + }); + + Logger.info("utils", `Creating ${count} test users`, invites); + + // Generate a bunch of invites + const response = await userInviter({ + user, + invites, + ip: ctx.request.ip, }); - Logger.info("utils", `Creating ${count} test users`, invites); + // Convert from invites to active users by marking as active + await Promise.all( + response.users.map((user) => user.updateActiveAt(ctx, true)) + ); - // Generate a bunch of invites - const response = await userInviter({ - user, - invites, - ip: ctx.request.ip, - }); - - // Convert from invites to active users by marking as active - await Promise.all( - response.users.map((user) => user.updateActiveAt(ctx, true)) - ); - - ctx.body = { - data: { - users: response.users.map((user) => presentUser(user)), - }, - }; -}); + ctx.body = { + data: { + users: response.users.map((user) => presentUser(user)), + }, + }; + } +); export default router; diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index d3cd1e745..75e8da8e0 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -70,7 +70,7 @@ router.post( } = ctx.input; // always filter by the current team - const { user } = ctx.state; + const { user } = ctx.state.auth; let where: WhereOptions = { teamId: user.teamId, archivedAt: { @@ -177,7 +177,7 @@ router.post( validate(T.DocumentsArchivedSchema), async (ctx: APIContext) => { const { sort, direction } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const collectionIds = await user.collectionIds(); const collectionScope: Readonly = { method: ["withCollectionPermissions", user.id], @@ -221,7 +221,7 @@ router.post( validate(T.DocumentsDeletedSchema), async (ctx: APIContext) => { const { sort, direction } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const collectionIds = await user.collectionIds({ paranoid: false, }); @@ -281,7 +281,7 @@ router.post( validate(T.DocumentsViewedSchema), async (ctx: APIContext) => { const { sort, direction } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const collectionIds = await user.collectionIds(); const userId = user.id; const views = await View.findAll({ @@ -334,7 +334,7 @@ router.post( validate(T.DocumentsDraftsSchema), async (ctx: APIContext) => { const { collectionId, dateFilter, direction, sort } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; if (collectionId) { const collection = await Collection.scope({ @@ -397,7 +397,7 @@ router.post( validate(T.DocumentsInfoSchema), async (ctx: APIContext) => { const { id, shareId, apiVersion } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const teamFromCtx = await getTeamFromContext(ctx); const { document, share, collection } = await documentLoader({ id, @@ -443,7 +443,7 @@ router.post( validate(T.DocumentsExportSchema), async (ctx: APIContext) => { const { id } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const accept = ctx.request.headers["accept"]; const { document } = await documentLoader({ @@ -495,7 +495,7 @@ router.post( validate(T.DocumentsRestoreSchema), async (ctx: APIContext) => { const { id, collectionId, revisionId } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(id, { userId: user.id, paranoid: false, @@ -606,7 +606,7 @@ router.post( userId, } = ctx.input; const { offset, limit } = ctx.state.pagination; - const { user } = ctx.state; + const { user } = ctx.state.auth; let collaboratorIds = undefined; if (collectionId) { @@ -663,9 +663,8 @@ router.post( } = ctx.input; const { offset, limit } = ctx.state.pagination; - // this typing is a bit ugly, would be better to use a type like ContextWithState - // but that doesn't adequately handle cases when auth is optional - const { user }: { user: User | undefined } = ctx.state; + // Unfortunately, this still doesn't adequately handle cases when auth is optional + const { user } = ctx.state.auth; let teamId; let response; @@ -747,7 +746,7 @@ router.post( userId: user?.id, teamId, shareId, - source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app" + source: ctx.state.auth.type || "app", // we'll consider anything that isn't "api" to be "app" query, results: totalCount, }); @@ -767,7 +766,7 @@ router.post( validate(T.DocumentsTemplatizeSchema), async (ctx: APIContext) => { const { id } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const original = await Document.findByPk(id, { userId: user.id, @@ -829,7 +828,7 @@ router.post( append, } = ctx.input; const editorVersion = ctx.headers["x-editor-version"] as string | undefined; - const { user } = ctx.state; + const { user } = ctx.state.auth; let collection: Collection | null | undefined; const document = await Document.findByPk(id, { @@ -889,7 +888,7 @@ router.post( validate(T.DocumentsMoveSchema), async (ctx: APIContext) => { const { id, collectionId, parentDocumentId, index } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(id, { userId: user.id, }); @@ -943,7 +942,7 @@ router.post( validate(T.DocumentsArchiveSchema), async (ctx: APIContext) => { const { id } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(id, { userId: user.id, @@ -976,7 +975,7 @@ router.post( validate(T.DocumentsDeleteSchema), async (ctx: APIContext) => { const { id, permanent } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; if (permanent) { const document = await Document.findByPk(id, { @@ -1041,7 +1040,7 @@ router.post( validate(T.DocumentsUnpublishSchema), async (ctx: APIContext) => { const { id } = ctx.input; - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(id, { userId: user.id, @@ -1103,7 +1102,7 @@ router.post( ); } - const { user } = ctx.state; + const { user } = ctx.state.auth; const collection = await Collection.scope({ method: ["withMembership", user.id], @@ -1177,7 +1176,7 @@ router.post( } = ctx.input; const editorVersion = ctx.headers["x-editor-version"] as string | undefined; - const { user } = ctx.state; + const { user } = ctx.state.auth; let collection; diff --git a/server/routes/api/events/events.ts b/server/routes/api/events/events.ts index 089c89f9b..e66af0528 100644 --- a/server/routes/api/events/events.ts +++ b/server/routes/api/events/events.ts @@ -17,7 +17,7 @@ router.post( pagination(), validate(T.EventsListSchema), async (ctx: APIContext) => { - const { user } = ctx.state; + const { user } = ctx.state.auth; const { sort, direction, diff --git a/server/routes/api/fileOperations.ts b/server/routes/api/fileOperations.ts index e522e683d..3143d84bc 100644 --- a/server/routes/api/fileOperations.ts +++ b/server/routes/api/fileOperations.ts @@ -7,33 +7,37 @@ import auth from "@server/middlewares/authentication"; import { FileOperation, Team } from "@server/models"; import { authorize } from "@server/policies"; import { presentFileOperation } from "@server/presenters"; -import { ContextWithState } from "@server/types"; +import { APIContext } from "@server/types"; import { getSignedUrl } from "@server/utils/s3"; import { assertIn, assertSort, assertUuid } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("fileOperations.info", auth({ admin: true }), async (ctx) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - const { user } = ctx.state; - const fileOperation = await FileOperation.findByPk(id, { - rejectOnEmpty: true, - }); +router.post( + "fileOperations.info", + auth({ admin: true }), + async (ctx: APIContext) => { + const { id } = ctx.request.body; + assertUuid(id, "id is required"); + const { user } = ctx.state.auth; + const fileOperation = await FileOperation.findByPk(id, { + rejectOnEmpty: true, + }); - authorize(user, "read", fileOperation); + authorize(user, "read", fileOperation); - ctx.body = { - data: presentFileOperation(fileOperation), - }; -}); + ctx.body = { + data: presentFileOperation(fileOperation), + }; + } +); router.post( "fileOperations.list", auth({ admin: true }), pagination(), - async (ctx) => { + async (ctx: APIContext) => { let { direction } = ctx.request.body; const { sort = "createdAt", type } = ctx.request.body; assertIn(type, Object.values(FileOperationType)); @@ -42,7 +46,7 @@ router.post( if (direction !== "ASC") { direction = "DESC"; } - const { user } = ctx.state; + const { user } = ctx.state.auth; const where: WhereOptions = { teamId: user.teamId, type, @@ -69,11 +73,11 @@ router.post( } ); -const handleFileOperationsRedirect = async (ctx: ContextWithState) => { +const handleFileOperationsRedirect = async (ctx: APIContext) => { const id = ctx.request.body?.id ?? ctx.request.query?.id; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const fileOperation = await FileOperation.unscoped().findByPk(id, { rejectOnEmpty: true, }); @@ -98,21 +102,25 @@ router.post( handleFileOperationsRedirect ); -router.post("fileOperations.delete", auth({ admin: true }), async (ctx) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); +router.post( + "fileOperations.delete", + auth({ admin: true }), + async (ctx: APIContext) => { + const { id } = ctx.request.body; + assertUuid(id, "id is required"); - const { user } = ctx.state; - const fileOperation = await FileOperation.unscoped().findByPk(id, { - rejectOnEmpty: true, - }); - authorize(user, "delete", fileOperation); + const { user } = ctx.state.auth; + const fileOperation = await FileOperation.unscoped().findByPk(id, { + rejectOnEmpty: true, + }); + authorize(user, "delete", fileOperation); - await fileOperationDeleter(fileOperation, user, ctx.request.ip); + await fileOperationDeleter(fileOperation, user, ctx.request.ip); - ctx.body = { - success: true, - }; -}); + ctx.body = { + success: true, + }; + } +); export default router; diff --git a/server/routes/api/groups.ts b/server/routes/api/groups.ts index 1fdbdc6fc..3ec923673 100644 --- a/server/routes/api/groups.ts +++ b/server/routes/api/groups.ts @@ -10,12 +10,13 @@ import { presentUser, presentGroupMembership, } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertPresent, assertUuid, assertSort } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("groups.list", auth(), pagination(), async (ctx) => { +router.post("groups.list", auth(), pagination(), async (ctx: APIContext) => { let { direction } = ctx.request.body; const { sort = "updatedAt" } = ctx.request.body; if (direction !== "ASC") { @@ -23,7 +24,7 @@ router.post("groups.list", auth(), pagination(), async (ctx) => { } assertSort(sort, Group); - const { user } = ctx.state; + const { user } = ctx.state.auth; const groups = await Group.findAll({ where: { teamId: user.teamId, @@ -52,11 +53,11 @@ router.post("groups.list", auth(), pagination(), async (ctx) => { }; }); -router.post("groups.info", auth(), async (ctx) => { +router.post("groups.info", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const group = await Group.findByPk(id); authorize(user, "read", group); @@ -66,11 +67,11 @@ router.post("groups.info", auth(), async (ctx) => { }; }); -router.post("groups.create", auth(), async (ctx) => { +router.post("groups.create", auth(), async (ctx: APIContext) => { const { name } = ctx.request.body; assertPresent(name, "name is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; authorize(user, "createGroup", user.team); const g = await Group.create({ name, @@ -98,12 +99,12 @@ router.post("groups.create", auth(), async (ctx) => { }; }); -router.post("groups.update", auth(), async (ctx) => { +router.post("groups.update", auth(), async (ctx: APIContext) => { const { id, name } = ctx.request.body; assertPresent(name, "name is required"); assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const group = await Group.findByPk(id); authorize(user, "update", group); @@ -129,11 +130,11 @@ router.post("groups.update", auth(), async (ctx) => { }; }); -router.post("groups.delete", auth(), async (ctx) => { +router.post("groups.delete", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const group = await Group.findByPk(id); authorize(user, "delete", group); @@ -154,61 +155,68 @@ router.post("groups.delete", auth(), async (ctx) => { }; }); -router.post("groups.memberships", auth(), pagination(), async (ctx) => { - const { id, query } = ctx.request.body; - assertUuid(id, "id is required"); +router.post( + "groups.memberships", + auth(), + pagination(), + async (ctx: APIContext) => { + const { id, query } = ctx.request.body; + assertUuid(id, "id is required"); - const { user } = ctx.state; - const group = await Group.findByPk(id); - authorize(user, "read", group); - let userWhere; + const { user } = ctx.state.auth; + const group = await Group.findByPk(id); + authorize(user, "read", group); + let userWhere; - if (query) { - userWhere = { - name: { - [Op.iLike]: `%${query}%`, + if (query) { + userWhere = { + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + const memberships = await GroupUser.findAll({ + where: { + groupId: id, + }, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + include: [ + { + model: User, + as: "user", + where: userWhere, + required: true, + }, + ], + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + groupMemberships: memberships.map((membership) => + presentGroupMembership(membership, { includeUser: true }) + ), + users: memberships.map((membership) => presentUser(membership.user)), }, }; } +); - const memberships = await GroupUser.findAll({ - where: { - groupId: id, - }, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - include: [ - { - model: User, - as: "user", - where: userWhere, - required: true, - }, - ], - }); - - ctx.body = { - pagination: ctx.state.pagination, - data: { - groupMemberships: memberships.map((membership) => - presentGroupMembership(membership, { includeUser: true }) - ), - users: memberships.map((membership) => presentUser(membership.user)), - }, - }; -}); - -router.post("groups.add_user", auth(), async (ctx) => { +router.post("groups.add_user", auth(), async (ctx: APIContext) => { const { id, userId } = ctx.request.body; assertUuid(id, "id is required"); assertUuid(userId, "userId is required"); + const actor = ctx.state.auth.user; + const user = await User.findByPk(userId); - authorize(ctx.state.user, "read", user); + authorize(actor, "read", user); let group = await Group.findByPk(id); - authorize(ctx.state.user, "update", group); + authorize(actor, "update", group); let membership = await GroupUser.findOne({ where: { @@ -220,7 +228,7 @@ router.post("groups.add_user", auth(), async (ctx) => { if (!membership) { await group.$add("user", user, { through: { - createdById: ctx.state.user.id, + createdById: actor.id, }, }); // reload to get default scope @@ -240,7 +248,7 @@ router.post("groups.add_user", auth(), async (ctx) => { userId, teamId: user.teamId, modelId: group.id, - actorId: ctx.state.user.id, + actorId: actor.id, data: { name: user.name, }, @@ -259,16 +267,18 @@ router.post("groups.add_user", auth(), async (ctx) => { }; }); -router.post("groups.remove_user", auth(), async (ctx) => { +router.post("groups.remove_user", auth(), async (ctx: APIContext) => { const { id, userId } = ctx.request.body; assertUuid(id, "id is required"); assertUuid(userId, "userId is required"); + const actor = ctx.state.auth.user; + let group = await Group.findByPk(id); - authorize(ctx.state.user, "update", group); + authorize(actor, "update", group); const user = await User.findByPk(userId); - authorize(ctx.state.user, "read", user); + authorize(actor, "read", user); await group.$remove("user", user); await Event.create({ @@ -276,7 +286,7 @@ router.post("groups.remove_user", auth(), async (ctx) => { userId, modelId: group.id, teamId: user.teamId, - actorId: ctx.state.user.id, + actorId: actor.id, data: { name: user.name, }, diff --git a/server/routes/api/hooks.ts b/server/routes/api/hooks.ts index d74900c77..6945d4197 100644 --- a/server/routes/api/hooks.ts +++ b/server/routes/api/hooks.ts @@ -17,6 +17,7 @@ import { } from "@server/models"; import SearchHelper from "@server/models/helpers/SearchHelper"; import { presentSlackAttachment } from "@server/presenters"; +import { APIContext } from "@server/types"; import * as Slack from "@server/utils/slack"; import { assertPresent } from "@server/validation"; @@ -41,7 +42,7 @@ function verifySlackToken(token: string) { } // triggered by a user posting a getoutline.com link in Slack -router.post("hooks.unfurl", async (ctx) => { +router.post("hooks.unfurl", async (ctx: APIContext) => { const { challenge, token, event } = ctx.request.body; if (challenge) { return (ctx.body = ctx.request.body.challenge); @@ -104,7 +105,7 @@ router.post("hooks.unfurl", async (ctx) => { }); // triggered by interactions with actions, dialogs, message buttons in Slack -router.post("hooks.interactive", async (ctx) => { +router.post("hooks.interactive", async (ctx: APIContext) => { const { payload } = ctx.request.body; assertPresent(payload, "payload is required"); @@ -142,7 +143,7 @@ router.post("hooks.interactive", async (ctx) => { }); // triggered by the /outline command in Slack -router.post("hooks.slack", async (ctx) => { +router.post("hooks.slack", async (ctx: APIContext) => { const { token, team_id, user_id, text = "" } = ctx.request.body; assertPresent(token, "token is required"); assertPresent(team_id, "team_id is required"); diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 69eabb8bb..ab53c7426 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -1,10 +1,10 @@ -import Koa, { BaseContext, DefaultContext, DefaultState } from "koa"; +import Koa, { BaseContext } from "koa"; import bodyParser from "koa-body"; import Router from "koa-router"; import userAgent, { UserAgentContext } from "koa-useragent"; import env from "@server/env"; import { NotFoundError } from "@server/errors"; -import { AuthenticatedState } from "@server/types"; +import { AppState, AppContext } from "@server/types"; import apiKeys from "./apiKeys"; import attachments from "./attachments"; import auth from "./auth"; @@ -32,10 +32,7 @@ import users from "./users"; import views from "./views"; import webhookSubscriptions from "./webhookSubscriptions"; -const api = new Koa< - DefaultState & AuthenticatedState, - DefaultContext & { body: Record } ->(); +const api = new Koa(); const router = new Router(); // middlewares diff --git a/server/routes/api/integrations.ts b/server/routes/api/integrations.ts index bfa521abd..b3c331407 100644 --- a/server/routes/api/integrations.ts +++ b/server/routes/api/integrations.ts @@ -9,6 +9,7 @@ import Integration, { } from "@server/models/Integration"; import { authorize } from "@server/policies"; import { presentIntegration } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertSort, assertUuid, @@ -20,116 +21,133 @@ import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("integrations.list", auth(), pagination(), async (ctx) => { - let { direction } = ctx.request.body; - const { user } = ctx.state; - const { type, sort = "updatedAt" } = ctx.request.body; - if (direction !== "ASC") { - direction = "DESC"; - } - assertSort(sort, Integration); +router.post( + "integrations.list", + auth(), + pagination(), + async (ctx: APIContext) => { + let { direction } = ctx.request.body; + const { user } = ctx.state.auth; + const { type, sort = "updatedAt" } = ctx.request.body; + if (direction !== "ASC") { + direction = "DESC"; + } + assertSort(sort, Integration); - let where: WhereOptions = { - teamId: user.teamId, - }; + let where: WhereOptions = { + teamId: user.teamId, + }; - if (type) { - assertIn(type, Object.values(IntegrationType)); - where = { - ...where, - type, + if (type) { + assertIn(type, Object.values(IntegrationType)); + where = { + ...where, + type, + }; + } + + const integrations = await Integration.findAll({ + where, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: integrations.map(presentIntegration), }; } +); - const integrations = await Integration.findAll({ - where, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); +router.post( + "integrations.create", + auth({ admin: true }), + async (ctx: APIContext) => { + const { type, service, settings } = ctx.request.body; - ctx.body = { - pagination: ctx.state.pagination, - data: integrations.map(presentIntegration), - }; -}); + assertIn(type, Object.values(IntegrationType)); -router.post("integrations.create", auth({ admin: true }), async (ctx) => { - const { type, service, settings } = ctx.request.body; + const { user } = ctx.state.auth; + authorize(user, "createIntegration", user.team); - assertIn(type, Object.values(IntegrationType)); + assertIn(service, Object.values(UserCreatableIntegrationService)); - const { user } = ctx.state; - authorize(user, "createIntegration", user.team); + if (has(settings, "url")) { + assertUrl(settings.url); + } - assertIn(service, Object.values(UserCreatableIntegrationService)); + const integration = await Integration.create({ + userId: user.id, + teamId: user.teamId, + service, + settings, + type, + }); - if (has(settings, "url")) { - assertUrl(settings.url); + ctx.body = { + data: presentIntegration(integration), + }; } +); - const integration = await Integration.create({ - userId: user.id, - teamId: user.teamId, - service, - settings, - type, - }); +router.post( + "integrations.update", + auth({ admin: true }), + async (ctx: APIContext) => { + const { id, events = [], settings } = ctx.request.body; + assertUuid(id, "id is required"); - ctx.body = { - data: presentIntegration(integration), - }; -}); + const { user } = ctx.state.auth; + const integration = await Integration.findByPk(id); + authorize(user, "update", integration); -router.post("integrations.update", auth({ admin: true }), async (ctx) => { - const { id, events = [], settings } = ctx.request.body; - assertUuid(id, "id is required"); + assertArray(events, "events must be an array"); - const { user } = ctx.state; - const integration = await Integration.findByPk(id); - authorize(user, "update", integration); + if (has(settings, "url")) { + assertUrl(settings.url); + } - assertArray(events, "events must be an array"); + if (integration.type === IntegrationType.Post) { + integration.events = events.filter((event: string) => + ["documents.update", "documents.publish"].includes(event) + ); + } - if (has(settings, "url")) { - assertUrl(settings.url); + integration.settings = settings; + + await integration.save(); + + ctx.body = { + data: presentIntegration(integration), + }; } +); - if (integration.type === IntegrationType.Post) { - integration.events = events.filter((event: string) => - ["documents.update", "documents.publish"].includes(event) - ); +router.post( + "integrations.delete", + auth({ admin: true }), + async (ctx: APIContext) => { + const { id } = ctx.request.body; + assertUuid(id, "id is required"); + + const { user } = ctx.state.auth; + const integration = await Integration.findByPk(id); + authorize(user, "delete", integration); + + await integration.destroy(); + await Event.create({ + name: "integrations.delete", + modelId: integration.id, + teamId: integration.teamId, + actorId: user.id, + ip: ctx.request.ip, + }); + + ctx.body = { + success: true, + }; } - - integration.settings = settings; - - await integration.save(); - - ctx.body = { - data: presentIntegration(integration), - }; -}); - -router.post("integrations.delete", auth({ admin: true }), async (ctx) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - - const { user } = ctx.state; - const integration = await Integration.findByPk(id); - authorize(user, "delete", integration); - - await integration.destroy(); - await Event.create({ - name: "integrations.delete", - modelId: integration.id, - teamId: integration.teamId, - actorId: user.id, - ip: ctx.request.ip, - }); - - ctx.body = { - success: true, - }; -}); +); export default router; diff --git a/server/routes/api/middlewares/pagination.ts b/server/routes/api/middlewares/pagination.ts index 201b95d4c..b2dba4560 100644 --- a/server/routes/api/middlewares/pagination.ts +++ b/server/routes/api/middlewares/pagination.ts @@ -1,14 +1,14 @@ import querystring from "querystring"; -import { Context, Next } from "koa"; +import { Next } from "koa"; import { InvalidRequestError } from "@server/errors"; +import { AppContext } from "@server/types"; -export default function pagination(options?: Record) { - return async function paginationMiddleware(ctx: Context, next: Next) { +export default function pagination() { + return async function paginationMiddleware(ctx: AppContext, next: Next) { const opts = { defaultLimit: 15, defaultOffset: 0, maxLimit: 100, - ...options, }; const query = ctx.request.query; const body = ctx.request.body; @@ -42,18 +42,15 @@ export default function pagination(options?: Record) { ); } + query.limit = String(limit); + query.offset = String(limit + offset); + ctx.state.pagination = { limit, offset, + nextPath: `/api${ctx.request.path}?${querystring.stringify(query)}`, }; - query.limit = ctx.state.pagination.limit; - query.offset = ctx.state.pagination.offset + query.limit; - - ctx.state.pagination.nextPath = `/api${ - ctx.request.path - }?${querystring.stringify(query)}`; - return next(); }; } diff --git a/server/routes/api/notificationSettings.ts b/server/routes/api/notificationSettings.ts index 5b0c5726f..f176a88ac 100644 --- a/server/routes/api/notificationSettings.ts +++ b/server/routes/api/notificationSettings.ts @@ -4,16 +4,16 @@ import auth from "@server/middlewares/authentication"; import { Team, NotificationSetting } from "@server/models"; import { authorize } from "@server/policies"; import { presentNotificationSetting } from "@server/presenters"; -import { ContextWithState } from "@server/types"; +import { APIContext } from "@server/types"; import { assertPresent, assertUuid } from "@server/validation"; const router = new Router(); -router.post("notificationSettings.create", auth(), async (ctx) => { +router.post("notificationSettings.create", auth(), async (ctx: APIContext) => { const { event } = ctx.request.body; assertPresent(event, "event is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; authorize(user, "createNotificationSetting", user.team); const [setting] = await NotificationSetting.findOrCreate({ where: { @@ -28,8 +28,8 @@ router.post("notificationSettings.create", auth(), async (ctx) => { }; }); -router.post("notificationSettings.list", auth(), async (ctx) => { - const { user } = ctx.state; +router.post("notificationSettings.list", auth(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const settings = await NotificationSetting.findAll({ where: { userId: user.id, @@ -41,11 +41,11 @@ router.post("notificationSettings.list", auth(), async (ctx) => { }; }); -router.post("notificationSettings.delete", auth(), async (ctx) => { +router.post("notificationSettings.delete", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const setting = await NotificationSetting.findByPk(id); authorize(user, "delete", setting); @@ -56,7 +56,7 @@ router.post("notificationSettings.delete", auth(), async (ctx) => { }; }); -const handleUnsubscribe = async (ctx: ContextWithState) => { +const handleUnsubscribe = async (ctx: APIContext) => { const { id, token } = (ctx.method === "POST" ? ctx.request.body : ctx.request.query) as { diff --git a/server/routes/api/pins.ts b/server/routes/api/pins.ts index d19e1d58d..7cc452b7b 100644 --- a/server/routes/api/pins.ts +++ b/server/routes/api/pins.ts @@ -11,17 +11,18 @@ import { presentDocument, presentPolicies, } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertUuid, assertIndexCharacters } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("pins.create", auth(), async (ctx) => { +router.post("pins.create", auth(), async (ctx: APIContext) => { const { documentId, collectionId } = ctx.request.body; const { index } = ctx.request.body; assertUuid(documentId, "documentId is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(documentId, { userId: user.id, }); @@ -55,9 +56,9 @@ router.post("pins.create", auth(), async (ctx) => { }; }); -router.post("pins.list", auth(), pagination(), async (ctx) => { +router.post("pins.list", auth(), pagination(), async (ctx: APIContext) => { const { collectionId } = ctx.request.body; - const { user } = ctx.state; + const { user } = ctx.state.auth; const [pins, collectionIds] = await Promise.all([ Pin.findAll({ @@ -98,13 +99,13 @@ router.post("pins.list", auth(), pagination(), async (ctx) => { }; }); -router.post("pins.update", auth(), async (ctx) => { +router.post("pins.update", auth(), async (ctx: APIContext) => { const { id, index } = ctx.request.body; assertUuid(id, "id is required"); assertIndexCharacters(index); - const { user } = ctx.state; + const { user } = ctx.state.auth; let pin = await Pin.findByPk(id, { rejectOnEmpty: true }); const document = await Document.findByPk(pin.documentId, { @@ -130,11 +131,11 @@ router.post("pins.update", auth(), async (ctx) => { }; }); -router.post("pins.delete", auth(), async (ctx) => { +router.post("pins.delete", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const pin = await Pin.findByPk(id, { rejectOnEmpty: true }); const document = await Document.findByPk(pin.documentId, { diff --git a/server/routes/api/revisions.ts b/server/routes/api/revisions.ts index fff1acabc..6ddd6dab7 100644 --- a/server/routes/api/revisions.ts +++ b/server/routes/api/revisions.ts @@ -6,16 +6,17 @@ import { Document, Revision } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import { authorize } from "@server/policies"; import { presentRevision } from "@server/presenters"; +import { APIContext } from "@server/types"; import slugify from "@server/utils/slugify"; import { assertPresent, assertSort, assertUuid } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("revisions.info", auth(), async (ctx) => { +router.post("revisions.info", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const revision = await Revision.findByPk(id, { rejectOnEmpty: true, }); @@ -38,11 +39,11 @@ router.post("revisions.info", auth(), async (ctx) => { }; }); -router.post("revisions.diff", auth(), async (ctx) => { +router.post("revisions.diff", auth(), async (ctx: APIContext) => { const { id, compareToId } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const revision = await Revision.findByPk(id, { rejectOnEmpty: true, }); @@ -92,7 +93,7 @@ router.post("revisions.diff", auth(), async (ctx) => { }; }); -router.post("revisions.list", auth(), pagination(), async (ctx) => { +router.post("revisions.list", auth(), pagination(), async (ctx: APIContext) => { let { direction } = ctx.request.body; const { documentId, sort = "updatedAt" } = ctx.request.body; if (direction !== "ASC") { @@ -101,7 +102,7 @@ router.post("revisions.list", auth(), pagination(), async (ctx) => { assertSort(sort, Revision); assertPresent(documentId, "documentId is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(documentId, { userId: user.id, }); diff --git a/server/routes/api/searches.ts b/server/routes/api/searches.ts index f9a3578af..5caf588cc 100644 --- a/server/routes/api/searches.ts +++ b/server/routes/api/searches.ts @@ -2,13 +2,14 @@ import Router from "koa-router"; import auth from "@server/middlewares/authentication"; import { SearchQuery } from "@server/models"; import { presentSearchQuery } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertPresent, assertUuid } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("searches.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; +router.post("searches.list", auth(), pagination(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const searches = await SearchQuery.findAll({ where: { @@ -25,14 +26,14 @@ router.post("searches.list", auth(), pagination(), async (ctx) => { }; }); -router.post("searches.delete", auth(), async (ctx) => { +router.post("searches.delete", auth(), async (ctx: APIContext) => { const { id, query } = ctx.request.body; assertPresent(id || query, "id or query is required"); if (id) { assertUuid(id, "id is must be a uuid"); } - const { user } = ctx.state; + const { user } = ctx.state.auth; await SearchQuery.destroy({ where: { ...(id ? { id } : { query }), diff --git a/server/routes/api/shares.ts b/server/routes/api/shares.ts index 73b17537a..c2e4b6495 100644 --- a/server/routes/api/shares.ts +++ b/server/routes/api/shares.ts @@ -6,12 +6,13 @@ 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) => { +router.post("shares.info", auth(), async (ctx: APIContext) => { const { id, documentId } = ctx.request.body; assertPresent(id || documentId, "id or documentId is required"); if (id) { @@ -21,7 +22,7 @@ router.post("shares.info", auth(), async (ctx) => { assertUuid(documentId, "documentId is must be a uuid"); } - const { user } = ctx.state; + const { user } = ctx.state.auth; const shares = []; const share = await Share.scope({ method: ["withCollectionPermissions", user.id], @@ -92,7 +93,7 @@ router.post("shares.info", auth(), async (ctx) => { }; }); -router.post("shares.list", auth(), pagination(), async (ctx) => { +router.post("shares.list", auth(), pagination(), async (ctx: APIContext) => { let { direction } = ctx.request.body; const { sort = "updatedAt" } = ctx.request.body; if (direction !== "ASC") { @@ -100,7 +101,7 @@ router.post("shares.list", auth(), pagination(), async (ctx) => { } assertSort(sort, Share); - const { user } = ctx.state; + const { user } = ctx.state.auth; const where: WhereOptions = { teamId: user.teamId, userId: user.id, @@ -162,11 +163,11 @@ router.post("shares.list", auth(), pagination(), async (ctx) => { }; }); -router.post("shares.update", auth(), async (ctx) => { +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; + const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId); authorize(user, "share", team); @@ -215,11 +216,11 @@ router.post("shares.update", auth(), async (ctx) => { }; }); -router.post("shares.create", auth(), async (ctx) => { +router.post("shares.create", auth(), async (ctx: APIContext) => { const { documentId } = ctx.request.body; assertPresent(documentId, "documentId is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(documentId, { userId: user.id, }); @@ -267,11 +268,11 @@ router.post("shares.create", auth(), async (ctx) => { }; }); -router.post("shares.revoke", auth(), async (ctx) => { +router.post("shares.revoke", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const share = await Share.findByPk(id); if (!share?.document) { diff --git a/server/routes/api/stars.ts b/server/routes/api/stars.ts index e3361cd10..bf72a252c 100644 --- a/server/routes/api/stars.ts +++ b/server/routes/api/stars.ts @@ -12,16 +12,17 @@ import { presentDocument, presentPolicies, } from "@server/presenters"; +import { APIContext } from "@server/types"; import { starIndexing } from "@server/utils/indexing"; import { assertUuid, assertIndexCharacters } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("stars.create", auth(), async (ctx) => { +router.post("stars.create", auth(), async (ctx: APIContext) => { const { documentId, collectionId } = ctx.request.body; const { index } = ctx.request.body; - const { user } = ctx.state; + const { user } = ctx.state.auth; assertUuid( documentId || collectionId, @@ -62,8 +63,8 @@ router.post("stars.create", auth(), async (ctx) => { }; }); -router.post("stars.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; +router.post("stars.list", auth(), pagination(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const [stars, collectionIds] = await Promise.all([ Star.findAll({ @@ -115,13 +116,13 @@ router.post("stars.list", auth(), pagination(), async (ctx) => { }; }); -router.post("stars.update", auth(), async (ctx) => { +router.post("stars.update", auth(), async (ctx: APIContext) => { const { id, index } = ctx.request.body; assertUuid(id, "id is required"); assertIndexCharacters(index); - const { user } = ctx.state; + const { user } = ctx.state.auth; let star = await Star.findByPk(id); authorize(user, "update", star); @@ -138,11 +139,11 @@ router.post("stars.update", auth(), async (ctx) => { }; }); -router.post("stars.delete", auth(), async (ctx) => { +router.post("stars.delete", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const star = await Star.findByPk(id); authorize(user, "delete", star); diff --git a/server/routes/api/subscriptions.ts b/server/routes/api/subscriptions.ts index 80a4ef871..d8bfc00f8 100644 --- a/server/routes/api/subscriptions.ts +++ b/server/routes/api/subscriptions.ts @@ -2,53 +2,56 @@ import Router from "koa-router"; import subscriptionCreator from "@server/commands/subscriptionCreator"; import subscriptionDestroyer from "@server/commands/subscriptionDestroyer"; import auth from "@server/middlewares/authentication"; -import { - transaction, - TransactionContext, -} from "@server/middlewares/transaction"; +import { transaction } from "@server/middlewares/transaction"; import { Subscription, Document } from "@server/models"; import { authorize } from "@server/policies"; import { presentSubscription } from "@server/presenters"; +import { APIContext } from "@server/types"; import { assertIn, assertUuid } from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); -router.post("subscriptions.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; - const { documentId, event } = ctx.request.body; +router.post( + "subscriptions.list", + auth(), + pagination(), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + const { documentId, event } = ctx.request.body; - assertUuid(documentId, "documentId is required"); + assertUuid(documentId, "documentId is required"); - assertIn( - event, - ["documents.update"], - `Not a valid subscription event for documents` - ); - - const document = await Document.findByPk(documentId, { userId: user.id }); - - authorize(user, "read", document); - - const subscriptions = await Subscription.findAll({ - where: { - documentId: document.id, - userId: user.id, + assertIn( event, - }, - order: [["createdAt", "DESC"]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); + ["documents.update"], + `Not a valid subscription event for documents` + ); - ctx.body = { - pagination: ctx.state.pagination, - data: subscriptions.map(presentSubscription), - }; -}); + const document = await Document.findByPk(documentId, { userId: user.id }); -router.post("subscriptions.info", auth(), async (ctx) => { - const { user } = ctx.state; + authorize(user, "read", document); + + const subscriptions = await Subscription.findAll({ + where: { + documentId: document.id, + userId: user.id, + event, + }, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: subscriptions.map(presentSubscription), + }; + } +); + +router.post("subscriptions.info", auth(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const { documentId, event } = ctx.request.body; assertUuid(documentId, "documentId is required"); @@ -82,8 +85,9 @@ router.post( "subscriptions.create", auth(), transaction(), - async (ctx: TransactionContext) => { - const { user, transaction } = ctx.state; + async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const { user } = auth; const { documentId, event } = ctx.request.body; assertUuid(documentId, "documentId is required"); @@ -119,8 +123,9 @@ router.post( "subscriptions.delete", auth(), transaction(), - async (ctx: TransactionContext) => { - const { user, transaction } = ctx.state; + async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const { user } = auth; const { id } = ctx.request.body; assertUuid(id, "id is required"); diff --git a/server/routes/api/team.ts b/server/routes/api/team.ts index 73f8cac7b..f6e3a38ec 100644 --- a/server/routes/api/team.ts +++ b/server/routes/api/team.ts @@ -8,6 +8,7 @@ import { rateLimiter } from "@server/middlewares/rateLimiter"; import { Event, Team, TeamDomain, User } from "@server/models"; import { authorize } from "@server/policies"; import { presentTeam, presentPolicies } from "@server/presenters"; +import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { assertUuid } from "@server/validation"; @@ -17,7 +18,7 @@ router.post( "team.update", auth(), rateLimiter(RateLimiterStrategy.TenPerHour), - async (ctx) => { + async (ctx: APIContext) => { const { name, avatarUrl, @@ -34,7 +35,7 @@ router.post( preferences, } = ctx.request.body; - const { user } = ctx.state; + const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId, { include: [{ model: TeamDomain }], }); @@ -76,8 +77,8 @@ router.post( "teams.create", auth(), rateLimiter(RateLimiterStrategy.FivePerHour), - async (ctx) => { - const { user } = ctx.state; + async (ctx: APIContext) => { + const { user } = ctx.state.auth; const { name } = ctx.request.body; const existingTeam = await Team.scope( diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index 08b6decad..1c58065e1 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -16,14 +16,12 @@ import { ValidationError } from "@server/errors"; import logger from "@server/logging/Logger"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; -import { - transaction, - TransactionContext, -} from "@server/middlewares/transaction"; +import { transaction } from "@server/middlewares/transaction"; import { Event, User, Team } from "@server/models"; import { UserFlag, UserRole } from "@server/models/User"; import { can, authorize } from "@server/policies"; import { presentUser, presentPolicies } from "@server/presenters"; +import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { assertIn, @@ -39,7 +37,7 @@ import pagination from "./middlewares/pagination"; const router = new Router(); const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development"); -router.post("users.list", auth(), pagination(), async (ctx) => { +router.post("users.list", auth(), pagination(), async (ctx: APIContext) => { let { direction } = ctx.request.body; const { sort = "createdAt", query, filter, ids } = ctx.request.body; if (direction !== "ASC") { @@ -55,7 +53,7 @@ router.post("users.list", auth(), pagination(), async (ctx) => { ); } - const actor = ctx.state.user; + const actor = ctx.state.auth.user; let where: WhereOptions = { teamId: actor.teamId, }; @@ -158,8 +156,8 @@ router.post("users.list", auth(), pagination(), async (ctx) => { }; }); -router.post("users.count", auth(), async (ctx) => { - const { user } = ctx.state; +router.post("users.count", auth(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const counts = await User.getCounts(user.teamId); ctx.body = { @@ -169,9 +167,9 @@ router.post("users.count", auth(), async (ctx) => { }; }); -router.post("users.info", auth(), async (ctx) => { +router.post("users.info", auth(), async (ctx: APIContext) => { const { id } = ctx.request.body; - const actor = ctx.state.user; + const actor = ctx.state.auth.user; const user = id ? await User.findByPk(id) : actor; authorize(actor, "read", user); const includeDetails = can(actor, "readDetails", user); @@ -184,57 +182,53 @@ router.post("users.info", auth(), async (ctx) => { }; }); -router.post( - "users.update", - auth(), - transaction(), - async (ctx: TransactionContext) => { - const { user, transaction } = ctx.state; - const { name, avatarUrl, language, preferences } = ctx.request.body; - if (name) { - user.name = name; - } - if (avatarUrl) { - user.avatarUrl = avatarUrl; - } - if (language) { - user.language = language; - } - if (preferences) { - assertKeysIn(preferences, UserPreference); +router.post("users.update", auth(), transaction(), async (ctx: APIContext) => { + const { auth, transaction } = ctx.state; + const { user } = auth; + const { name, avatarUrl, language, preferences } = ctx.request.body; + if (name) { + user.name = name; + } + if (avatarUrl) { + user.avatarUrl = avatarUrl; + } + if (language) { + user.language = language; + } + if (preferences) { + assertKeysIn(preferences, UserPreference); - for (const value of Object.values(UserPreference)) { - if (has(preferences, value)) { - assertBoolean(preferences[value]); - user.setPreference(value, preferences[value]); - } + for (const value of Object.values(UserPreference)) { + if (has(preferences, value)) { + assertBoolean(preferences[value]); + user.setPreference(value, preferences[value]); } } - await user.save({ transaction }); - await Event.create( - { - name: "users.update", - actorId: user.id, - userId: user.id, - teamId: user.teamId, - ip: ctx.request.ip, - }, - { transaction } - ); - - ctx.body = { - data: presentUser(user, { - includeDetails: true, - }), - }; } -); + await user.save({ transaction }); + await Event.create( + { + name: "users.update", + actorId: user.id, + userId: user.id, + teamId: user.teamId, + ip: ctx.request.ip, + }, + { transaction } + ); + + ctx.body = { + data: presentUser(user, { + includeDetails: true, + }), + }; +}); // Admin specific -router.post("users.promote", auth(), async (ctx) => { +router.post("users.promote", auth(), async (ctx: APIContext) => { const userId = ctx.request.body.id; - const teamId = ctx.state.user.teamId; - const actor = ctx.state.user; + const actor = ctx.state.auth.user; + const teamId = actor.teamId; assertPresent(userId, "id is required"); const user = await User.findByPk(userId); authorize(actor, "promote", user); @@ -260,10 +254,10 @@ router.post("users.promote", auth(), async (ctx) => { }; }); -router.post("users.demote", auth(), async (ctx) => { +router.post("users.demote", auth(), async (ctx: APIContext) => { const userId = ctx.request.body.id; let { to } = ctx.request.body; - const actor = ctx.state.user as User; + const actor = ctx.state.auth.user; assertPresent(userId, "id is required"); to = (to === "viewer" ? "viewer" : "member") as UserRole; @@ -289,9 +283,9 @@ router.post("users.demote", auth(), async (ctx) => { }; }); -router.post("users.suspend", auth(), async (ctx) => { +router.post("users.suspend", auth(), async (ctx: APIContext) => { const userId = ctx.request.body.id; - const actor = ctx.state.user; + const actor = ctx.state.auth.user; assertPresent(userId, "id is required"); const user = await User.findByPk(userId, { rejectOnEmpty: true, @@ -313,9 +307,9 @@ router.post("users.suspend", auth(), async (ctx) => { }; }); -router.post("users.activate", auth(), async (ctx) => { +router.post("users.activate", auth(), async (ctx: APIContext) => { const userId = ctx.request.body.id; - const actor = ctx.state.user; + const actor = ctx.state.auth.user; assertPresent(userId, "id is required"); const user = await User.findByPk(userId, { rejectOnEmpty: true, @@ -341,10 +335,10 @@ router.post( "users.invite", auth(), rateLimiter(RateLimiterStrategy.TenPerHour), - async (ctx) => { + async (ctx: APIContext) => { const { invites } = ctx.request.body; assertArray(invites, "invites must be an array"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId); authorize(user, "inviteUser", team); @@ -367,10 +361,10 @@ router.post( "users.resendInvite", auth(), transaction(), - async (ctx: TransactionContext) => { + async (ctx: APIContext) => { const { id } = ctx.request.body; - const actor = ctx.state.user; - const { transaction } = ctx.state; + const { auth, transaction } = ctx.state; + const actor = auth.user; const user = await User.findByPk(id, { lock: transaction.LOCK.UPDATE, @@ -413,8 +407,8 @@ router.post( "users.requestDelete", auth(), rateLimiter(RateLimiterStrategy.FivePerHour), - async (ctx) => { - const { user } = ctx.state; + async (ctx: APIContext) => { + const { user } = ctx.state.auth; authorize(user, "delete", user); if (emailEnabled) { @@ -434,9 +428,9 @@ router.post( "users.delete", auth(), rateLimiter(RateLimiterStrategy.TenPerHour), - async (ctx) => { + async (ctx: APIContext) => { const { id, code = "" } = ctx.request.body; - const actor = ctx.state.user; + const actor = ctx.state.auth.user; let user: User; if (id) { diff --git a/server/routes/api/views.ts b/server/routes/api/views.ts index 1937d90e7..613f2bb46 100644 --- a/server/routes/api/views.ts +++ b/server/routes/api/views.ts @@ -4,16 +4,17 @@ import { rateLimiter } from "@server/middlewares/rateLimiter"; import { View, Document, Event } from "@server/models"; import { authorize } from "@server/policies"; import { presentView } from "@server/presenters"; +import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { assertUuid } from "@server/validation"; const router = new Router(); -router.post("views.list", auth(), async (ctx) => { +router.post("views.list", auth(), async (ctx: APIContext) => { const { documentId, includeSuspended = false } = ctx.request.body; assertUuid(documentId, "documentId is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(documentId, { userId: user.id, }); @@ -29,11 +30,11 @@ router.post( "views.create", auth(), rateLimiter(RateLimiterStrategy.OneThousandPerHour), - async (ctx) => { + async (ctx: APIContext) => { const { documentId } = ctx.request.body; assertUuid(documentId, "documentId is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const document = await Document.findByPk(documentId, { userId: user.id, }); diff --git a/server/routes/api/webhookSubscriptions.ts b/server/routes/api/webhookSubscriptions.ts index e60bf04fb..aa4cb3467 100644 --- a/server/routes/api/webhookSubscriptions.ts +++ b/server/routes/api/webhookSubscriptions.ts @@ -5,7 +5,7 @@ import auth from "@server/middlewares/authentication"; import { WebhookSubscription, Event } from "@server/models"; import { authorize } from "@server/policies"; import { presentWebhookSubscription } from "@server/presenters"; -import { WebhookSubscriptionEvent } from "@server/types"; +import { WebhookSubscriptionEvent, APIContext } from "@server/types"; import { assertArray, assertPresent, assertUuid } from "@server/validation"; import pagination from "./middlewares/pagination"; @@ -15,8 +15,8 @@ router.post( "webhookSubscriptions.list", auth({ admin: true }), pagination(), - async (ctx) => { - const { user } = ctx.state; + async (ctx: APIContext) => { + const { user } = ctx.state.auth; authorize(user, "listWebhookSubscription", user.team); const webhooks = await WebhookSubscription.findAll({ where: { @@ -37,8 +37,8 @@ router.post( router.post( "webhookSubscriptions.create", auth({ admin: true }), - async (ctx) => { - const { user } = ctx.state; + async (ctx: APIContext) => { + const { user } = ctx.state.auth; authorize(user, "createWebhookSubscription", user.team); const { name, url, secret } = ctx.request.body; @@ -83,10 +83,10 @@ router.post( router.post( "webhookSubscriptions.delete", auth({ admin: true }), - async (ctx) => { + async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const webhookSubscription = await WebhookSubscription.findByPk(id); authorize(user, "delete", webhookSubscription); @@ -112,10 +112,10 @@ router.post( router.post( "webhookSubscriptions.update", auth({ admin: true }), - async (ctx) => { + async (ctx: APIContext) => { const { id } = ctx.request.body; assertUuid(id, "id is required"); - const { user } = ctx.state; + const { user } = ctx.state.auth; const { name, url, secret } = ctx.request.body; const events: string[] = compact(ctx.request.body.events); diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts index b12392d6c..8a2a0be55 100644 --- a/server/routes/auth/index.ts +++ b/server/routes/auth/index.ts @@ -6,9 +6,10 @@ import Router from "koa-router"; import { AuthenticationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { Collection, Team, View } from "@server/models"; +import { AppState, AppContext, APIContext } from "@server/types"; import providers from "./providers"; -const app = new Koa(); +const app = new Koa(); const router = new Router(); router.use(passport.initialize()); @@ -20,8 +21,8 @@ providers.forEach((provider) => { } }); -router.get("/redirect", auth(), async (ctx) => { - const { user } = ctx.state; +router.get("/redirect", auth(), async (ctx: APIContext) => { + const { user } = ctx.state.auth; const jwtToken = user.getJwtToken(); if (jwtToken === ctx.params.token) { diff --git a/server/types.ts b/server/types.ts index bb5c7d937..7f81465d4 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,5 +1,6 @@ -import { Context } from "koa"; -import { RouterContext } from "koa-router"; +import { ParameterizedContext, DefaultContext } from "koa"; +import { IRouterParamContext } from "koa-router"; +import { Transaction } from "sequelize/types"; import { Client } from "@shared/types"; import { AccountProvisionerResult } from "./commands/accountProvisioner"; import { FileOperation, Team, User } from "./models"; @@ -13,18 +14,32 @@ export type AuthenticationResult = AccountProvisionerResult & { client: Client; }; -export type AuthenticatedState = { +export type Authentication = { user: User; token: string; - authType: AuthenticationType; + type: AuthenticationType; }; -export type ContextWithState = Context & { - state: AuthenticatedState; +export type Pagination = { + limit: number; + offset: number; + nextPath: string; }; -export interface APIContext> - extends RouterContext { +export type AppState = { + auth: Authentication | Record; + transaction: Transaction; + pagination: Pagination; +}; + +export type AppContext = ParameterizedContext; + +export interface APIContext, ResT = unknown> + extends ParameterizedContext< + AppState, + DefaultContext & IRouterParamContext, + ResT + > { input: ReqT; }