RocketChat/Rocket.Chat

View on GitHub
apps/meteor/app/api/server/api.ts

Summary

Maintainability
F
1 wk
Test Coverage
import type { IMethodConnection, IUser, IRoom } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings';
import { Accounts } from 'meteor/accounts-base';
import { DDP } from 'meteor/ddp';
import { DDPCommon } from 'meteor/ddp-common';
import { Meteor } from 'meteor/meteor';
import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit';
import { RateLimiter } from 'meteor/rate-limit';
import type { Request, Response } from 'meteor/rocketchat:restivus';
import { Restivus } from 'meteor/rocketchat:restivus';
import _ from 'underscore';

import { isObject } from '../../../lib/utils/isObject';
import { getNestedProp } from '../../../server/lib/getNestedProp';
import { getRestPayload } from '../../../server/lib/logger/logPayloads';
import { checkCodeForUser } from '../../2fa/server/code';
import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission';
import { notifyOnUserChangeAsync } from '../../lib/server/lib/notifyListener';
import { metrics } from '../../metrics/server';
import { settings } from '../../settings/server';
import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields';
import type { PermissionsPayload } from './api.helpers';
import { checkPermissionsForInvocation, checkPermissions, parseDeprecation } from './api.helpers';
import type {
    FailureResult,
    InternalError,
    NotFoundResult,
    Operations,
    Options,
    PartialThis,
    SuccessResult,
    UnauthorizedResult,
} from './definition';
import { getUserInfo } from './helpers/getUserInfo';
import { parseJsonQuery } from './helpers/parseJsonQuery';

const logger = new Logger('API');

interface IAPIProperties {
    useDefaultAuth: boolean;
    prettyJson: boolean;
    auth: { token: string; user: () => Promise<{ userId: string; token: string }> };
    defaultOptionsEndpoint?: () => Promise<void>;
    version?: string;
    enableCors?: boolean;
    apiPath?: string;
}

interface IAPIDefaultFieldsToExclude {
    avatarOrigin: number;
    emails: number;
    phone: number;
    statusConnection: number;
    createdAt: number;
    lastLogin: number;
    services: number;
    requirePasswordChange: number;
    requirePasswordChangeReason: number;
    roles: number;
    statusDefault: number;
    _updatedAt: number;
    settings: number;
    inviteToken: number;
}

type RateLimiterOptions = {
    numRequestsAllowed?: number;
    intervalTimeInMS?: number;
};

export const defaultRateLimiterOptions: RateLimiterOptions = {
    numRequestsAllowed: settings.get<number>('API_Enable_Rate_Limiter_Limit_Calls_Default'),
    intervalTimeInMS: settings.get<number>('API_Enable_Rate_Limiter_Limit_Time_Default'),
};
const rateLimiterDictionary: Record<
    string,
    {
        rateLimiter: RateLimiter;
        options: RateLimiterOptions;
    }
> = {};

const getRequestIP = (req: Request): string | null => {
    const socket = req.socket || req.connection?.socket;
    const remoteAddress =
        req.headers['x-real-ip'] || (typeof socket !== 'string' && (socket?.remoteAddress || req.connection?.remoteAddress || null));
    let forwardedFor = req.headers['x-forwarded-for'];

    if (!socket) {
        return remoteAddress || forwardedFor || null;
    }

    const httpForwardedCount = parseInt(String(process.env.HTTP_FORWARDED_COUNT)) || 0;
    if (httpForwardedCount <= 0) {
        return remoteAddress;
    }

    if (!forwardedFor || typeof forwardedFor.valueOf() !== 'string') {
        return remoteAddress;
    }

    forwardedFor = forwardedFor.trim().split(/\s*,\s*/);
    if (httpForwardedCount > forwardedFor.length) {
        return remoteAddress;
    }

    return forwardedFor[forwardedFor.length - httpForwardedCount];
};

const generateConnection = (
    ipAddress: string,
    httpHeaders: Record<string, any>,
): {
    id: string;
    close: () => void;
    clientAddress: string;
    httpHeaders: Record<string, any>;
} => ({
    id: Random.id(),
    // eslint-disable-next-line @typescript-eslint/no-empty-function
    close() {},
    httpHeaders,
    clientAddress: ipAddress,
});

let prometheusAPIUserAgent = false;

export class APIClass<TBasePath extends string = ''> extends Restivus {
    protected apiPath?: string;

    public authMethods: ((...args: any[]) => any)[];

    protected helperMethods: Map<string, () => any> = new Map();

    public fieldSeparator: string;

    public defaultFieldsToExclude: {
        joinCode: number;
        members: number;
        importIds: number;
        e2e: number;
    };

    public defaultLimitedUserFieldsToExclude: IAPIDefaultFieldsToExclude;

    public limitedUserFieldsToExclude: IAPIDefaultFieldsToExclude;

    public limitedUserFieldsToExcludeIfIsPrivilegedUser: {
        services: number;
        inviteToken: number;
    };

    constructor(properties: IAPIProperties) {
        super(properties);
        this.apiPath = properties.apiPath;
        this.authMethods = [];
        this.fieldSeparator = '.';
        this.defaultFieldsToExclude = {
            joinCode: 0,
            members: 0,
            importIds: 0,
            e2e: 0,
        };
        this.defaultLimitedUserFieldsToExclude = {
            avatarOrigin: 0,
            emails: 0,
            phone: 0,
            statusConnection: 0,
            createdAt: 0,
            lastLogin: 0,
            services: 0,
            requirePasswordChange: 0,
            requirePasswordChangeReason: 0,
            roles: 0,
            statusDefault: 0,
            _updatedAt: 0,
            settings: 0,
            inviteToken: 0,
        };
        this.limitedUserFieldsToExclude = this.defaultLimitedUserFieldsToExclude;
        this.limitedUserFieldsToExcludeIfIsPrivilegedUser = {
            services: 0,
            inviteToken: 0,
        };
    }

    public setLimitedCustomFields(customFields: string[]): void {
        const nonPublicFieds = customFields.reduce((acc, customField) => {
            acc[`customFields.${customField}`] = 0;
            return acc;
        }, {} as Record<string, any>);
        this.limitedUserFieldsToExclude = {
            ...this.defaultLimitedUserFieldsToExclude,
            ...nonPublicFieds,
        };
    }

    async parseJsonQuery(this: PartialThis) {
        return parseJsonQuery(this);
    }

    public addAuthMethod(func: (this: PartialThis, ...args: any[]) => any): void {
        this.authMethods.push(func);
    }

    protected shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean {
        const { version } = this._config;
        const { rateLimiterOptions } = options;
        return (
            (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) &&
            Boolean(version) &&
            !process.env.TEST_MODE &&
            Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS)
        );
    }

    public success(): SuccessResult<void>;

    public success<T>(result: T): SuccessResult<T>;

    public success<T>(result: T = {} as T): SuccessResult<T> {
        if (isObject(result)) {
            (result as Record<string, any>).success = true;
        }

        const finalResult = {
            statusCode: 200,
            body: result,
        } as SuccessResult<T>;

        return finalResult as SuccessResult<T>;
    }

    public failure<T>(result?: T): FailureResult<T>;

    public failure<T, TErrorType extends string, TStack extends string, TErrorDetails>(
        result?: T,
        errorType?: TErrorType,
        stack?: TStack,
        error?: { details: TErrorDetails },
    ): FailureResult<T, TErrorType, TStack, TErrorDetails>;

    public failure<T, TErrorType extends string, TStack extends string, TErrorDetails>(
        result?: T,
        errorType?: TErrorType,
        stack?: TStack,
        error?: { details: TErrorDetails },
    ): FailureResult<T> {
        const response: {
            statusCode: 400;
            body: any & { message?: string; errorType?: string; stack?: string; success?: boolean; details?: Record<string, any> | string };
        } = { statusCode: 400, body: result };

        if (isObject(result)) {
            response.body.success = false;
        } else {
            response.body = {
                success: false,
                error: result,
                stack,
            };

            if (errorType) {
                response.body.errorType = errorType;
            }

            if (error && typeof error === 'object' && 'details' in error && error?.details) {
                try {
                    response.body.details = JSON.parse(error.details as unknown as string);
                } catch (e) {
                    response.body.details = error.details;
                }
            }
        }

        return response;
    }

    public notFound(msg?: string): NotFoundResult {
        return {
            statusCode: 404,
            body: {
                success: false,
                error: msg || 'Resource not found',
            },
        };
    }

    public internalError<T>(msg?: T): InternalError<T> {
        return {
            statusCode: 500,
            body: {
                success: false,
                error: msg || 'Internal error occured',
            },
        };
    }

    public unauthorized<T>(msg?: T): UnauthorizedResult<T> {
        return {
            statusCode: 403,
            body: {
                success: false,
                error: msg || 'unauthorized',
            },
        };
    }

    public tooManyRequests(msg?: string): { statusCode: number; body: Record<string, any> & { success?: boolean } } {
        return {
            statusCode: 429,
            body: {
                success: false,
                error: msg || 'Too many requests',
            },
        };
    }

    protected getRateLimiter(route: string): { rateLimiter: RateLimiter; options: RateLimiterOptions } {
        return rateLimiterDictionary[route];
    }

    protected async shouldVerifyRateLimit(route: string, userId: string): Promise<boolean> {
        return (
            rateLimiterDictionary.hasOwnProperty(route) &&
            settings.get<boolean>('API_Enable_Rate_Limiter') === true &&
            (process.env.NODE_ENV !== 'development' || settings.get<boolean>('API_Enable_Rate_Limiter_Dev') === true) &&
            !(userId && (await hasPermissionAsync(userId, 'api-bypass-rate-limit')))
        );
    }

    protected async enforceRateLimit(
        objectForRateLimitMatch: RateLimiterOptionsToCheck,
        _: any,
        response: Response,
        userId: string,
    ): Promise<void> {
        if (!(await this.shouldVerifyRateLimit(objectForRateLimitMatch.route, userId))) {
            return;
        }

        rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch);
        const attemptResult = await rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch);
        const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000);
        response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed);
        response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft);
        response.setHeader('X-RateLimit-Reset', new Date().getTime() + attemptResult.timeToReset);

        if (!attemptResult.allowed) {
            throw new Meteor.Error(
                'error-too-many-requests',
                `Error, too many requests. Please slow down. You must wait ${timeToResetAttempsInSeconds} seconds before trying this endpoint again.`,
                {
                    timeToReset: attemptResult.timeToReset,
                    seconds: timeToResetAttempsInSeconds,
                },
            );
        }
    }

    public reloadRoutesToRefreshRateLimiter(): void {
        const { version } = this._config;
        this._routes.forEach((route) => {
            if (this.shouldAddRateLimitToRoute(route.options)) {
                this.addRateLimiterRuleForRoutes({
                    routes: [route.path],
                    rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions,
                    endpoints: Object.keys(route.endpoints).filter((endpoint) => endpoint !== 'options'),
                    apiVersion: version,
                });
            }
        });
    }

    protected addRateLimiterRuleForRoutes({
        routes,
        rateLimiterOptions,
        endpoints,
        apiVersion,
    }: {
        routes: string[];
        rateLimiterOptions: RateLimiterOptions | boolean;
        endpoints: string[];
        apiVersion?: string;
    }): void {
        if (typeof rateLimiterOptions !== 'object') {
            throw new Meteor.Error('"rateLimiterOptions" must be an object');
        }
        if (!rateLimiterOptions.numRequestsAllowed) {
            throw new Meteor.Error('You must set "numRequestsAllowed" property in rateLimiter for REST API endpoint');
        }
        if (!rateLimiterOptions.intervalTimeInMS) {
            throw new Meteor.Error('You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint');
        }
        const addRateLimitRuleToEveryRoute = (routes: string[]) => {
            routes.forEach((route) => {
                rateLimiterDictionary[route] = {
                    rateLimiter: new RateLimiter(),
                    options: rateLimiterOptions,
                };
                const rateLimitRule = {
                    IPAddr: (input: any) => input,
                    route,
                };
                rateLimiterDictionary[route].rateLimiter.addRule(
                    rateLimitRule,
                    rateLimiterOptions.numRequestsAllowed as number,
                    rateLimiterOptions.intervalTimeInMS as number,
                );
            });
        };
        routes.map((route) => this.namedRoutes(route, endpoints, apiVersion)).map(addRateLimitRuleToEveryRoute);
    }

    public async processTwoFactor({
        userId,
        request,
        invocation,
        options,
        connection,
    }: {
        userId: string;
        request: Request;
        invocation: { twoFactorChecked?: boolean };
        options?: Options;
        connection: IMethodConnection;
    }): Promise<void> {
        if (options && (!('twoFactorRequired' in options) || !options.twoFactorRequired)) {
            return;
        }
        const code = request.headers['x-2fa-code'];
        const method = request.headers['x-2fa-method'];

        await checkCodeForUser({
            user: userId,
            code,
            method,
            options: options && 'twoFactorOptions' in options ? (options as Record<string, any>).twoFactorOptions || {} : {},
            connection,
        });

        invocation.twoFactorChecked = true;
    }

    protected getFullRouteName(route: string, method: string, apiVersion?: string): string {
        let prefix = `/${this.apiPath || ''}`;
        if (apiVersion) {
            prefix += `${apiVersion}/`;
        }
        return `${prefix}${route}${method}`;
    }

    protected namedRoutes(route: string, endpoints: Record<string, string> | string[], apiVersion?: string): string[] {
        const routeActions: string[] = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints);

        return routeActions.map((action) => this.getFullRouteName(route, action, apiVersion));
    }

    addRoute<TSubPathPattern extends string>(
        subpath: TSubPathPattern,
        operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>>,
    ): void;

    addRoute<TSubPathPattern extends string, TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>>(
        subpaths: TSubPathPattern[],
        operations: Operations<TPathPattern>,
    ): void;

    addRoute<TSubPathPattern extends string, TOptions extends Options>(
        subpath: TSubPathPattern,
        options: TOptions,
        operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>, TOptions>,
    ): void;

    addRoute<TSubPathPattern extends string, TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>, TOptions extends Options>(
        subpaths: TSubPathPattern[],
        options: TOptions,
        operations: Operations<TPathPattern, TOptions>,
    ): void;

    public addRoute<
        TSubPathPattern extends string,
        TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>,
        TOptions extends Options,
    >(subpaths: TSubPathPattern[], options: TOptions, endpoints?: Operations<TPathPattern, TOptions>): void {
        // Note: required if the developer didn't provide options
        if (endpoints === undefined) {
            endpoints = options as unknown as Operations<TPathPattern>;
            options = {} as TOptions;
        }

        const operations = endpoints;

        const shouldVerifyPermissions = checkPermissions(options);

        // Allow for more than one route using the same option and endpoints
        if (!Array.isArray(subpaths)) {
            subpaths = [subpaths];
        }
        const { version } = this._config;
        if (this.shouldAddRateLimitToRoute(options)) {
            this.addRateLimiterRuleForRoutes({
                routes: subpaths,
                rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions,
                endpoints: operations as unknown as string[],
                apiVersion: version,
            });
        }
        subpaths.forEach((route) => {
            // Note: This is required due to Restivus calling `addRoute` in the constructor of itself
            Object.keys(operations).forEach((method) => {
                const _options = { ...options };

                if (typeof operations[method as keyof Operations<TPathPattern, TOptions>] === 'function') {
                    (operations as Record<string, any>)[method as string] = {
                        action: operations[method as keyof Operations<TPathPattern, TOptions>],
                    };
                } else {
                    const extraOptions: Record<string, any> = { ...operations[method as keyof Operations<TPathPattern, TOptions>] } as Record<
                        string,
                        any
                    >;
                    delete extraOptions.action;
                    Object.assign(_options, extraOptions);
                }
                // Add a try/catch for each endpoint
                const originalAction = (operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action;
                // eslint-disable-next-line @typescript-eslint/no-this-alias
                const api = this;
                (operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action =
                    async function _internalRouteActionHandler() {
                        const rocketchatRestApiEnd = metrics.rocketchatRestApi.startTimer({
                            method,
                            version,
                            ...(prometheusAPIUserAgent && { user_agent: this.request.headers['user-agent'] }),
                            entrypoint: route.startsWith('method.call') ? decodeURIComponent(this.request._parsedUrl.pathname.slice(8)) : route,
                        });

                        this.requestIp = getRequestIP(this.request);

                        const startTime = Date.now();

                        const log = logger.logger.child({
                            method: this.request.method,
                            url: this.request.url,
                            userId: this.request.headers['x-user-id'],
                            userAgent: this.request.headers['user-agent'],
                            length: this.request.headers['content-length'],
                            host: this.request.headers.host,
                            referer: this.request.headers.referer,
                            remoteIP: this.requestIp,
                            ...getRestPayload(this.request.body),
                        });

                        // If the endpoint requires authentication only if anonymous read is disabled, load the user info if it was provided
                        if (!options.authRequired && options.authOrAnonRequired) {
                            const { 'x-user-id': userId, 'x-auth-token': userToken } = this.request.headers;
                            if (userId && userToken) {
                                this.user = await Users.findOne(
                                    {
                                        'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(userToken),
                                        '_id': userId,
                                    },
                                    {
                                        projection: getDefaultUserFields(),
                                    },
                                );

                                this.userId = this.user?._id;
                            }

                            if (!this.user && !settings.get('Accounts_AllowAnonymousRead')) {
                                return {
                                    statusCode: 401,
                                    body: {
                                        status: 'error',
                                        message: 'You must be logged in to do this.',
                                    },
                                };
                            }
                        }

                        const objectForRateLimitMatch = {
                            IPAddr: this.requestIp,
                            route: `${this.request.route}${this.request.method.toLowerCase()}`,
                        };

                        let result;

                        const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token };

                        try {
                            if (options.deprecation) {
                                parseDeprecation(this, options.deprecation);
                            }

                            await api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId);

                            if (_options.validateParams) {
                                const requestMethod = this.request.method as Method;
                                const validatorFunc =
                                    typeof _options.validateParams === 'function' ? _options.validateParams : _options.validateParams[requestMethod];

                                if (validatorFunc && !validatorFunc(requestMethod === 'GET' ? this.queryParams : this.bodyParams)) {
                                    throw new Meteor.Error('invalid-params', validatorFunc.errors?.map((error: any) => error.message).join('\n '));
                                }
                            }
                            if (
                                shouldVerifyPermissions &&
                                (!this.userId ||
                                    !(await checkPermissionsForInvocation(
                                        this.userId,
                                        _options.permissionsRequired as PermissionsPayload,
                                        this.request.method,
                                    )))
                            ) {
                                throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', {
                                    permissions: _options.permissionsRequired,
                                });
                            }

                            const invocation = new DDPCommon.MethodInvocation({
                                connection,
                                isSimulation: false,
                                userId: this.userId,
                            });

                            Accounts._accountData[connection.id] = {
                                connection,
                            };
                            Accounts._setAccountData(connection.id, 'loginToken', this.token);

                            await api.processTwoFactor({
                                userId: this.userId,
                                request: this.request,
                                invocation: invocation as unknown as Record<string, any>,
                                options: _options,
                                connection: connection as unknown as IMethodConnection,
                            });

                            this.queryOperations = options.queryOperations;
                            this.queryFields = options.queryFields;
                            this.parseJsonQuery = api.parseJsonQuery.bind(this as PartialThis);

                            result =
                                (await DDP._CurrentInvocation.withValue(invocation as any, async () => originalAction.apply(this))) || API.v1.success();

                            log.http({
                                status: result.statusCode,
                                responseTime: Date.now() - startTime,
                            });
                        } catch (e: any) {
                            const apiMethod: string =
                                {
                                    'error-too-many-requests': 'tooManyRequests',
                                    'error-unauthorized': 'unauthorized',
                                }[e.error as string] || 'failure';

                            result = (API.v1 as Record<string, any>)[apiMethod](
                                typeof e === 'string' ? e : e.message,
                                e.error,
                                process.env.TEST_MODE ? e.stack : undefined,
                                e,
                            );

                            log.http({
                                err: e,
                                status: result.statusCode,
                                responseTime: Date.now() - startTime,
                            });
                        } finally {
                            delete Accounts._accountData[connection.id];
                        }

                        rocketchatRestApiEnd({
                            status: result.statusCode,
                        });

                        return result;
                    };

                // Allow the endpoints to make usage of the logger which respects the user's settings
                (operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).logger = logger;
            });

            super.addRoute(route, options, operations);
        });
    }

    public updateRateLimiterDictionaryForRoute(route: string, numRequestsAllowed: number, intervalTimeInMS?: number): void {
        if (rateLimiterDictionary[route]) {
            rateLimiterDictionary[route].options.numRequestsAllowed =
                numRequestsAllowed ?? rateLimiterDictionary[route].options.numRequestsAllowed;
            rateLimiterDictionary[route].options.intervalTimeInMS = intervalTimeInMS ?? rateLimiterDictionary[route].options.intervalTimeInMS;
            API.v1?.reloadRoutesToRefreshRateLimiter();
        }
    }

    protected _initAuth(): void {
        const loginCompatibility = (bodyParams: Record<string, any>, request: Request): Record<string, any> => {
            // Grab the username or email that the user is logging in with
            const { user, username, email, password, code: bodyCode } = bodyParams;
            let usernameToLDAPLogin = '';

            if (password == null) {
                return bodyParams;
            }

            if (_.without(Object.keys(bodyParams), 'user', 'username', 'email', 'password', 'code').length > 0) {
                return bodyParams;
            }

            const code = bodyCode || request.headers['x-2fa-code'];

            const auth: Record<string, any> = {
                password,
            };

            if (typeof user === 'string') {
                auth.user = user.includes('@') ? { email: user } : { username: user };
                usernameToLDAPLogin = user;
            } else if (username) {
                auth.user = { username };
                usernameToLDAPLogin = username;
            } else if (email) {
                auth.user = { email };
                usernameToLDAPLogin = email;
            }

            if (auth.user == null) {
                return bodyParams;
            }

            if (auth.password.hashed) {
                auth.password = {
                    digest: auth.password as { hashed: string },
                    algorithm: 'sha-256',
                };
            }

            const objectToLDAPLogin = {
                ldap: true,
                username: usernameToLDAPLogin,
                ldapPass: auth.password,
                ldapOptions: {},
            };
            if (settings.get<boolean>('LDAP_Enable') && !code) {
                return objectToLDAPLogin;
            }

            if (code) {
                return {
                    totp: {
                        code,
                        login: settings.get('LDAP_Enable') ? objectToLDAPLogin : auth,
                    },
                };
            }

            return auth;
        };

        // eslint-disable-next-line @typescript-eslint/no-this-alias
        const self = this;

        (this as APIClass<'/v1'>).addRoute<'/v1/login', { authRequired: false }>(
            'login' as any,
            { authRequired: false },
            {
                async post() {
                    const request = this.request as unknown as Request;
                    const args = loginCompatibility(this.bodyParams, request);

                    const invocation = new DDPCommon.MethodInvocation({
                        connection: generateConnection(getRequestIP(request) || '', this.request.headers),
                    });

                    let auth;
                    try {
                        auth = await DDP._CurrentInvocation.withValue(invocation as any, async () => Meteor.callAsync('login', args));
                    } catch (error: any) {
                        let e = error;
                        if (error.reason === 'User not found') {
                            e = {
                                error: 'Unauthorized',
                                reason: 'Unauthorized',
                            };
                        }

                        return {
                            statusCode: 401,
                            body: {
                                status: 'error',
                                error: e.error,
                                details: e.details,
                                message: e.reason || e.message,
                            },
                        } as unknown as SuccessResult<Record<string, any>>;
                    }

                    this.user = await Users.findOne(
                        {
                            _id: auth.id,
                        },
                        {
                            projection: getDefaultUserFields(),
                        },
                    );

                    this.userId = (this.user as unknown as IUser)?._id;

                    const response = {
                        status: 'success',
                        data: {
                            userId: this.userId,
                            authToken: auth.token,
                            me: await getUserInfo(this.user || ({} as IUser)),
                        },
                    };

                    const extraData = self._config.onLoggedIn?.call(this);

                    if (extraData != null) {
                        _.extend(response.data, {
                            extra: extraData,
                        });
                    }

                    return response as unknown as SuccessResult<Record<string, any>>;
                },
            },
        );

        const logout = async function (this: Restivus): Promise<{ status: string; data: { message: string } }> {
            // Remove the given auth token from the user's account
            const authToken = this.request.headers['x-auth-token'];
            const hashedToken = Accounts._hashLoginToken(authToken);
            const tokenLocation = self._config?.auth?.token;
            const index = tokenLocation?.lastIndexOf('.') || 0;
            const tokenPath = tokenLocation?.substring(0, index) || '';
            const tokenFieldName = tokenLocation?.substring(index + 1) || '';
            const tokenToRemove: Record<string, any> = {};
            tokenToRemove[tokenFieldName] = hashedToken;
            const tokenRemovalQuery: Record<string, any> = {};
            tokenRemovalQuery[tokenPath] = tokenToRemove;

            await Users.updateOne(
                { _id: this.user._id },
                {
                    $pull: tokenRemovalQuery,
                },
            );

            // TODO this can be optmized so places that care about loginTokens being removed are invoked directly
            // instead of having to listen to every watch.users event
            void notifyOnUserChangeAsync(async () => {
                const userTokens = await Users.findOneById(this.user._id, { projection: { [tokenPath]: 1 } });
                if (!userTokens) {
                    return;
                }

                const diff = { [tokenPath]: getNestedProp(userTokens, tokenPath) };

                return { clientAction: 'updated', id: this.user._id, diff };
            });

            const response = {
                status: 'success',
                data: {
                    message: "You've been logged out!",
                },
            };

            // Call the logout hook with the authenticated user attached
            const extraData = self._config.onLoggedOut?.call(this);
            if (extraData != null) {
                _.extend(response.data, {
                    extra: extraData,
                });
            }
            return response;
        };

        /*
            Add a logout endpoint to the API
            After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for
            adding hook).
        */
        return (this as APIClass<'/v1'>).addRoute<'/v1/logout', { authRequired: true }>(
            'logout' as any,
            {
                authRequired: true,
            },
            {
                async get() {
                    console.warn('Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead.');
                    console.warn('    See https://github.com/kahmali/meteor-restivus/issues/100');
                    return logout.call(this as unknown as Restivus) as any;
                },
                async post() {
                    return logout.call(this as unknown as Restivus) as any;
                },
            },
        );
    }
}

const getUserAuth = function _getUserAuth(...args: any[]): {
    token: string;
    user: (this: Restivus) => Promise<{ userId: string; token: string }>;
} {
    const invalidResults = [undefined, null, false];
    return {
        token: 'services.resume.loginTokens.hashedToken',
        async user() {
            if (this.bodyParams?.payload) {
                this.bodyParams = JSON.parse(this.bodyParams.payload);
            }

            for await (const method of API.v1?.authMethods || []) {
                if (typeof method === 'function') {
                    const result = await method.apply(this, args);
                    if (!invalidResults.includes(result)) {
                        return result;
                    }
                }
            }

            let token;
            if (this.request.headers['x-auth-token']) {
                token = Accounts._hashLoginToken(this.request.headers['x-auth-token']);
            }

            this.token = token || '';

            return {
                userId: this.request.headers['x-user-id'],
                token,
            };
        },
    };
};

const defaultOptionsEndpoint = async function _defaultOptionsEndpoint(this: Restivus): Promise<void> {
    // check if a pre-flight request
    if (!this.request.headers['access-control-request-method'] && !this.request.headers.origin) {
        this.done();
        return;
    }

    if (!settings.get('API_Enable_CORS')) {
        this.response.writeHead(405);
        this.response.write('CORS not enabled. Go to "Admin > General > REST Api" to enable it.');
        this.done();
        return;
    }

    const CORSOriginSetting = String(settings.get('API_CORS_Origin'));

    const defaultHeaders = {
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH',
        'Access-Control-Allow-Headers':
            'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization',
    };

    if (CORSOriginSetting === '*') {
        this.response.writeHead(200, {
            'Access-Control-Allow-Origin': '*',
            ...defaultHeaders,
        });
        this.done();
        return;
    }

    const origins = CORSOriginSetting.trim()
        .split(',')
        .map((origin) => String(origin).trim().toLocaleLowerCase());

    // if invalid origin reply without required CORS headers
    if (!origins.includes(this.request.headers.origin)) {
        this.done();
        return;
    }

    this.response.writeHead(200, {
        'Access-Control-Allow-Origin': this.request.headers.origin,
        'Vary': 'Origin',
        ...defaultHeaders,
    });
    this.done();
};

const createApi = function _createApi(options: { version?: string } = {}): APIClass {
    return new APIClass(
        Object.assign(
            {
                apiPath: 'api/',
                useDefaultAuth: true,
                prettyJson: process.env.NODE_ENV === 'development',
                defaultOptionsEndpoint,
                auth: getUserAuth(),
            },
            options,
        ) as IAPIProperties,
    );
};

export const API: {
    v1: APIClass<'/v1'>;
    default: APIClass;
    getUserAuth: () => { token: string; user: (this: Restivus) => Promise<{ userId: string; token: string }> };
    ApiClass: typeof APIClass;
    channels?: {
        create: {
            validate: (params: {
                user: { value: string };
                name?: { key: string; value?: string };
                members?: { key: string; value?: string[] };
                customFields?: { key: string; value?: string };
                teams?: { key: string; value?: string[] };
            }) => Promise<void>;
            execute: (
                userId: string,
                params: {
                    name?: string;
                    members?: string[];
                    customFields?: Record<string, any>;
                    extraData?: Record<string, any>;
                    readOnly?: boolean;
                },
            ) => Promise<{ channel: IRoom }>;
        };
    };
} = {
    getUserAuth,
    ApiClass: APIClass,
    v1: createApi({
        version: 'v1',
    }),
    default: createApi(),
};

// register the API to be re-created once the CORS-setting changes.
settings.watchMultiple(['API_Enable_CORS', 'API_CORS_Origin'], () => {
    API.v1 = createApi({
        version: 'v1',
    });

    API.default = createApi();
});

settings.watch<string>('Accounts_CustomFields', (value) => {
    if (!value) {
        return API.v1?.setLimitedCustomFields([]);
    }
    try {
        const customFields = JSON.parse(value);
        const nonPublicCustomFields = Object.keys(customFields).filter((customFieldKey) => customFields[customFieldKey].public !== true);
        API.v1.setLimitedCustomFields(nonPublicCustomFields);
    } catch (error) {
        console.warn('Invalid Custom Fields', error);
    }
});

settings.watch<number>('API_Enable_Rate_Limiter_Limit_Time_Default', (value) => {
    defaultRateLimiterOptions.intervalTimeInMS = value;
    API.v1.reloadRoutesToRefreshRateLimiter();
});

settings.watch<number>('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => {
    defaultRateLimiterOptions.numRequestsAllowed = value;
    API.v1.reloadRoutesToRefreshRateLimiter();
});

settings.watch<boolean>('Prometheus_API_User_Agent', (value) => {
    prometheusAPIUserAgent = value;
});