Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
1246 lines
37 KiB
TypeScript
1246 lines
37 KiB
TypeScript
import type { IMethodConnection, IUser } from '@rocket.chat/core-typings';
|
|
import type { Route, Router } from '@rocket.chat/http-router';
|
|
import { License } from '@rocket.chat/license';
|
|
import { Logger } from '@rocket.chat/logger';
|
|
import { Sessions, Users } from '@rocket.chat/models';
|
|
import { Random } from '@rocket.chat/random';
|
|
import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings';
|
|
import { ajv } from '@rocket.chat/rest-typings';
|
|
import { wrapExceptions } from '@rocket.chat/tools';
|
|
import type { ValidateFunction } from 'ajv';
|
|
import { Accounts } from 'meteor/accounts-base';
|
|
import { DDP } from 'meteor/ddp';
|
|
// eslint-disable-next-line import-x/no-duplicates
|
|
import { DDPCommon } from 'meteor/ddp-common';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import type { RateLimiterOptionsToCheck } from 'meteor/rate-limit';
|
|
// eslint-disable-next-line import-x/no-duplicates
|
|
import { RateLimiter } from 'meteor/rate-limit';
|
|
import _ from 'underscore';
|
|
|
|
import { canBypassRateLimit, checkPermissions, parseDeprecation } from './api.helpers';
|
|
import type {
|
|
FailureResult,
|
|
ForbiddenResult,
|
|
InnerAction,
|
|
InternalError,
|
|
NotFoundResult,
|
|
Operations,
|
|
Options,
|
|
RateLimiterOptions,
|
|
SuccessResult,
|
|
TypedThis,
|
|
TypedAction,
|
|
TypedOptions,
|
|
UnauthorizedResult,
|
|
RedirectStatusCodes,
|
|
RedirectResult,
|
|
UnavailableResult,
|
|
GenericRouteExecutionContext,
|
|
TooManyRequestsResult,
|
|
SuccessStatusCodes,
|
|
} from './definition';
|
|
import { getUserInfo } from './lib/getUserInfo';
|
|
import { parseJsonQuery } from './lib/parseJsonQuery';
|
|
import type { APIActionContext } from './router';
|
|
import { RocketChatAPIRouter } from './router';
|
|
import { isObject } from '../../lib/utils/isObject';
|
|
import { checkCodeForUser } from '../lib/2fa/code';
|
|
import { getNestedProp } from '../lib/getNestedProp';
|
|
import { notifyOnUserChangeAsync } from '../lib/notifyListener';
|
|
import { shouldBreakInVersion } from '../lib/shouldBreakInVersion';
|
|
import { authenticationMiddlewareForHono } from './v1/middlewares/authenticationHono';
|
|
import { permissionsMiddleware } from './v1/middlewares/permissions';
|
|
import { license } from '../../ee/server/api/v1/middlewares/license';
|
|
import { getDefaultUserFields } from '../lib/utils/functions/getDefaultUserFields';
|
|
import { settings } from '../settings';
|
|
|
|
const logger = new Logger('API');
|
|
|
|
// We have some breaking changes planned to the API.
|
|
// To avoid conflicts or missing something during the period we are adopting a 'feature flag approach'
|
|
// TODO: MAJOR check if this is still needed
|
|
export const applyBreakingChanges = shouldBreakInVersion('9.0.0');
|
|
type MinimalRoute = {
|
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
path: string;
|
|
} & TypedOptions;
|
|
|
|
export type Prettify<T> = {
|
|
[K in keyof T]: T[K];
|
|
} & unknown;
|
|
|
|
type ExtractValidation<T> = T extends ValidateFunction<infer TSchema> ? TSchema : never;
|
|
|
|
type UnionToIntersection<U> = (U extends any ? (x: U) => any : never) extends (x: infer I) => any ? I : never;
|
|
|
|
export type ExtractRoutesFromAPI<T> = Prettify<
|
|
UnionToIntersection<
|
|
T extends APIClass<any, infer TOperations> ? (TOperations extends MinimalRoute ? Prettify<ConvertToRoute<TOperations>> : never) : never
|
|
>
|
|
>;
|
|
|
|
type ConvertToRoute<TRoute extends MinimalRoute> = {
|
|
[K in TRoute['path']]: {
|
|
[K2 in Extract<TRoute, { path: K }>['method']]: K2 extends 'GET' | 'DELETE'
|
|
? (
|
|
...args: [ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['query']>] extends [never]
|
|
? [params?: never]
|
|
: [params: ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['query']>]
|
|
) => ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['response'][200]>
|
|
: K2 extends 'POST' | 'PUT'
|
|
? (
|
|
params: ExtractValidation<Extract<TRoute, { path: K; method: K2 }>['body']>,
|
|
) => ExtractValidation<
|
|
200 extends keyof Extract<TRoute, { path: K; method: K2 }>['response']
|
|
? Extract<TRoute, { path: K; method: K2 }>['response'][200]
|
|
: 201 extends keyof Extract<TRoute, { path: K; method: K2 }>['response']
|
|
? Extract<TRoute, { path: K; method: K2 }>['response'][201]
|
|
: never
|
|
>
|
|
: never;
|
|
};
|
|
};
|
|
|
|
interface IAPIProperties {
|
|
useDefaultAuth: boolean;
|
|
prettyJson: boolean;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
> = {};
|
|
|
|
export 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,
|
|
});
|
|
|
|
export class APIClass<TBasePath extends string = '', TOperations extends Record<string, unknown> = Record<string, never>> {
|
|
public typedRoutes: Record<string, Record<string, Route>> = {};
|
|
|
|
protected apiPath?: string;
|
|
|
|
readonly version?: string;
|
|
|
|
private _routes: { path: string; options: Options; endpoints: Record<string, string> }[] = [];
|
|
|
|
public authMethods: ((routeContext: APIActionContext) => Promise<IUser | undefined>)[];
|
|
|
|
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;
|
|
};
|
|
|
|
readonly router: Router<any, any, any>;
|
|
|
|
constructor({ useDefaultAuth, ...properties }: IAPIProperties) {
|
|
this.version = properties.version;
|
|
|
|
this.apiPath = [properties.apiPath, properties.version].filter(Boolean).join('/').replaceAll('//', '/');
|
|
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,
|
|
};
|
|
this.router = new RocketChatAPIRouter(`/${this.apiPath}`.replace(/\/$/, '').replaceAll('//', '/'));
|
|
|
|
if (useDefaultAuth) {
|
|
this._initAuth();
|
|
}
|
|
}
|
|
|
|
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(routeContext: GenericRouteExecutionContext) {
|
|
return parseJsonQuery(routeContext);
|
|
}
|
|
|
|
public addAuthMethod(func: (routeContext: APIActionContext) => Promise<IUser | undefined>): void {
|
|
this.authMethods.push(func);
|
|
}
|
|
|
|
protected shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean {
|
|
const { rateLimiterOptions } = options;
|
|
return (
|
|
(typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) &&
|
|
Boolean(this.version) &&
|
|
!process.env.TEST_MODE &&
|
|
Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS)
|
|
);
|
|
}
|
|
|
|
public success(): SuccessResult<void>;
|
|
|
|
public success<T>(result: T, statusCode?: SuccessStatusCodes): SuccessResult<T>;
|
|
|
|
public success<T>(result: T = {} as T, statusCode: SuccessStatusCodes = 200): SuccessResult<T> {
|
|
if (isObject(result)) {
|
|
(result as Record<string, any>).success = true;
|
|
}
|
|
|
|
const finalResult = {
|
|
statusCode,
|
|
body: result,
|
|
} as SuccessResult<T>;
|
|
|
|
return finalResult;
|
|
}
|
|
|
|
public redirect<T, C extends RedirectStatusCodes>(code: C, result: T): RedirectResult<T, C> {
|
|
return {
|
|
statusCode: code,
|
|
body: result,
|
|
};
|
|
}
|
|
|
|
public failure(): FailureResult<string>;
|
|
|
|
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(): InternalError<never>;
|
|
|
|
public internalError<T>(msg: T): InternalError<T>;
|
|
|
|
public internalError<T>(msg?: T): InternalError<T> {
|
|
return {
|
|
statusCode: 500,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'Internal server error',
|
|
},
|
|
};
|
|
}
|
|
|
|
public unavailable<T>(msg?: T): UnavailableResult<T> {
|
|
return {
|
|
statusCode: 503,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'Service Unavailable',
|
|
},
|
|
};
|
|
}
|
|
|
|
public unauthorized(): UnauthorizedResult<string>;
|
|
|
|
public unauthorized<T>(msg: T): UnauthorizedResult<T>;
|
|
|
|
public unauthorized<T>(msg?: T): UnauthorizedResult<T> {
|
|
return {
|
|
statusCode: 401,
|
|
body: {
|
|
success: false,
|
|
error: msg || 'unauthorized',
|
|
},
|
|
};
|
|
}
|
|
|
|
public forbidden(): ForbiddenResult<string>;
|
|
|
|
public forbidden<T>(msg: T): ForbiddenResult<T>;
|
|
|
|
public forbidden<T>(msg?: T): ForbiddenResult<T> {
|
|
return {
|
|
statusCode: 403,
|
|
body: {
|
|
success: false,
|
|
// TODO: MAJOR remove 'unauthorized' in favor of 'forbidden'
|
|
// because of reasons beyond my control we were used to send `unauthorized` to 403 cases, to avoid a breaking change we just adapted here
|
|
// but thanks to the semver check tests should break as soon we bump to a new version
|
|
error: msg || (applyBreakingChanges ? 'forbidden' : 'unauthorized'),
|
|
},
|
|
};
|
|
}
|
|
|
|
public tooManyRequests<T>(msg?: T): TooManyRequestsResult<T> {
|
|
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 canBypassRateLimit(userId, rateLimiterDictionary[route].options.bypassPermissions)))
|
|
);
|
|
}
|
|
|
|
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.headers.set(
|
|
'X-RateLimit-Limit',
|
|
String(rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed ?? ''),
|
|
);
|
|
response.headers.set('X-RateLimit-Remaining', String(attemptResult.numInvocationsLeft));
|
|
response.headers.set('X-RateLimit-Reset', String(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 registerRateLimiterForRoute({
|
|
route,
|
|
rateLimiterOptions = defaultRateLimiterOptions,
|
|
methods,
|
|
}: {
|
|
route: string;
|
|
rateLimiterOptions?: RateLimiterOptions;
|
|
methods: string[];
|
|
}): void {
|
|
if (!this.shouldAddRateLimitToRoute({ rateLimiterOptions })) {
|
|
return;
|
|
}
|
|
|
|
this.addRateLimiterRuleForRoutes({ routes: [route], rateLimiterOptions, endpoints: methods });
|
|
}
|
|
|
|
public enforceRateLimitForRoute({
|
|
route,
|
|
method,
|
|
request,
|
|
response,
|
|
requestIp,
|
|
userId,
|
|
}: {
|
|
route: string;
|
|
method: string;
|
|
request: Request;
|
|
response: Response;
|
|
requestIp: string;
|
|
userId?: string;
|
|
}): Promise<void> {
|
|
return this.enforceRateLimit({ IPAddr: requestIp, route: this.getFullRouteName(route, method) }, request, response, userId);
|
|
}
|
|
|
|
public reloadRoutesToRefreshRateLimiter(): void {
|
|
this._routes.forEach((route) => {
|
|
if (this.shouldAddRateLimitToRoute(route.options)) {
|
|
wrapExceptions(() =>
|
|
this.addRateLimiterRuleForRoutes({
|
|
routes: [route.path],
|
|
rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions,
|
|
endpoints: Object.keys(route.endpoints).filter((endpoint) => endpoint !== 'options'),
|
|
}),
|
|
).catch((error) => {
|
|
console.error(error.message);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
protected addRateLimiterRuleForRoutes({
|
|
routes,
|
|
rateLimiterOptions,
|
|
endpoints,
|
|
}: {
|
|
routes: string[];
|
|
rateLimiterOptions: RateLimiterOptions | boolean;
|
|
endpoints: Record<string, string> | 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: ${routes}`);
|
|
}
|
|
if (!rateLimiterOptions.intervalTimeInMS) {
|
|
throw new Meteor.Error(`You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint: ${routes}`);
|
|
}
|
|
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)).map(addRateLimitRuleToEveryRoute);
|
|
}
|
|
|
|
public async processTwoFactor({
|
|
userId,
|
|
request,
|
|
options,
|
|
connection,
|
|
}: {
|
|
userId: string;
|
|
request: Request;
|
|
options?: Options;
|
|
connection: IMethodConnection;
|
|
}): Promise<boolean> {
|
|
if (options && (!('twoFactorRequired' in options) || !options.twoFactorRequired)) {
|
|
return false;
|
|
}
|
|
const code = request.headers.get('x-2fa-code') ? String(request.headers.get('x-2fa-code')) : undefined;
|
|
const method = request.headers.get('x-2fa-method') ? String(request.headers.get('x-2fa-method')) : undefined;
|
|
|
|
await checkCodeForUser({
|
|
user: userId,
|
|
code,
|
|
method,
|
|
options: options && 'twoFactorOptions' in options ? (options as Record<string, any>).twoFactorOptions || {} : {},
|
|
connection,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
public getFullRouteName(route: string, method: string): string {
|
|
return `/${this.apiPath || ''}/${route}${method}`;
|
|
}
|
|
|
|
protected namedRoutes(route: string, endpoints: Record<string, string> | string[]): string[] {
|
|
const routeActions: string[] = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints);
|
|
|
|
return routeActions.map((action) => this.getFullRouteName(route, action));
|
|
}
|
|
|
|
private registerTypedRoutesLegacy<TSubPathPattern extends string, TOptions extends Options>(
|
|
method: Method,
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
): void {
|
|
const { authRequired, validateParams } = options;
|
|
|
|
const opt = {
|
|
authRequired,
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'get' &&
|
|
('GET' in validateParams
|
|
? { query: validateParams.GET }
|
|
: {
|
|
query: validateParams as ValidateFunction<any>,
|
|
})),
|
|
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'post' &&
|
|
('POST' in validateParams ? { query: validateParams.POST } : { body: validateParams as ValidateFunction<any> })),
|
|
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'put' &&
|
|
('PUT' in validateParams ? { query: validateParams.PUT } : { body: validateParams as ValidateFunction<any> })),
|
|
...(validateParams &&
|
|
method.toLowerCase() === 'delete' &&
|
|
('DELETE' in validateParams ? { query: validateParams.DELETE } : { body: validateParams as ValidateFunction<any> })),
|
|
|
|
tags: ['Missing Documentation'],
|
|
response: {
|
|
200: ajv.compile({
|
|
type: 'object',
|
|
properties: {
|
|
success: { type: 'boolean' },
|
|
error: { type: 'string' },
|
|
},
|
|
required: ['success'],
|
|
}),
|
|
},
|
|
};
|
|
|
|
this.registerTypedRoutes(method, subpath, opt);
|
|
}
|
|
|
|
private registerTypedRoutes<
|
|
TSubPathPattern extends string,
|
|
TOptions extends TypedOptions,
|
|
TPathPattern extends `${TBasePath}/${TSubPathPattern}`,
|
|
>(method: MinimalRoute['method'], subpath: TSubPathPattern, options: TOptions): void {
|
|
const path = `/${this.apiPath}/${subpath}`.replaceAll('//', '/') as TPathPattern;
|
|
this.typedRoutes = this.typedRoutes || {};
|
|
this.typedRoutes[path] = this.typedRoutes[path] || {};
|
|
const { query, authRequired, response, body, tags, ...rest } = options;
|
|
this.typedRoutes[path][method.toLowerCase()] = {
|
|
...(response && {
|
|
responses: Object.fromEntries(
|
|
Object.entries(response).map(([status, schema]) => [
|
|
status,
|
|
{
|
|
description: '',
|
|
content: {
|
|
'application/json': 'schema' in schema ? { schema: schema.schema } : schema,
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
}),
|
|
...(query && {
|
|
parameters: [
|
|
{
|
|
schema: query.schema,
|
|
in: 'query',
|
|
name: 'query',
|
|
required: true,
|
|
},
|
|
],
|
|
}),
|
|
...(body && {
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': { schema: body.schema },
|
|
},
|
|
},
|
|
}),
|
|
...(authRequired && {
|
|
...rest,
|
|
security: [
|
|
{
|
|
userId: [],
|
|
authToken: [],
|
|
},
|
|
],
|
|
}),
|
|
tags,
|
|
};
|
|
}
|
|
|
|
private method<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
method: MinimalRoute['method'],
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| Prettify<
|
|
{
|
|
method: Method;
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>
|
|
>
|
|
> {
|
|
this.addRoute([subpath], { tags: [], ...options, typed: true }, { [method.toLowerCase()]: { action } } as any);
|
|
this.registerTypedRoutes(method, subpath, options);
|
|
return this;
|
|
}
|
|
|
|
get<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| Prettify<
|
|
{
|
|
method: 'GET';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('GET', subpath, options, action);
|
|
}
|
|
|
|
post<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| ({
|
|
method: 'POST';
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>)
|
|
| Prettify<
|
|
{
|
|
method: 'POST';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('POST', subpath, options, action);
|
|
}
|
|
|
|
put<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| ({
|
|
method: 'PUT';
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>)
|
|
| Prettify<
|
|
{
|
|
method: 'PUT';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('PUT', subpath, options, action);
|
|
}
|
|
|
|
delete<TSubPathPattern extends string, TOptions extends TypedOptions, TPathPattern extends `${TBasePath}/${TSubPathPattern}`>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
action: TypedAction<TOptions, TSubPathPattern>,
|
|
): APIClass<
|
|
TBasePath,
|
|
| TOperations
|
|
| ({
|
|
method: 'DELETE';
|
|
path: TPathPattern;
|
|
} & Omit<TOptions, 'response'>)
|
|
| Prettify<
|
|
{
|
|
method: 'DELETE';
|
|
path: TPathPattern;
|
|
} & TOptions
|
|
>
|
|
> {
|
|
return this.method('DELETE', subpath, options, action);
|
|
}
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string>(
|
|
subpath: TSubPathPattern,
|
|
operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string, TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>>(
|
|
subpaths: TSubPathPattern[],
|
|
operations: Operations<TPathPattern>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string, TOptions extends Options>(
|
|
subpath: TSubPathPattern,
|
|
options: TOptions,
|
|
operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>, TOptions>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
addRoute<TSubPathPattern extends string, TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>, TOptions extends Options>(
|
|
subpaths: TSubPathPattern[],
|
|
options: TOptions,
|
|
operations: Operations<TPathPattern, TOptions>,
|
|
): void;
|
|
|
|
/**
|
|
* @deprecated The addRoute method is deprecated. Please use the new route registration methods (get, post, put OR delete).
|
|
*/
|
|
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;
|
|
|
|
checkPermissions(options);
|
|
|
|
// Allow for more than one route using the same option and endpoints
|
|
if (!Array.isArray(subpaths)) {
|
|
subpaths = [subpaths];
|
|
}
|
|
if (this.shouldAddRateLimitToRoute(options)) {
|
|
this.addRateLimiterRuleForRoutes({
|
|
routes: subpaths,
|
|
rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions,
|
|
endpoints: operations as unknown as string[],
|
|
});
|
|
}
|
|
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 };
|
|
const { tags = ['Missing Documentation'] } = _options as Record<string, any>;
|
|
|
|
if (typeof operations[method as keyof Operations<TPathPattern, TOptions>] === 'function') {
|
|
(operations as Record<string, any>)[method] = {
|
|
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;
|
|
|
|
if (options.deprecation && shouldBreakInVersion(options.deprecation.version)) {
|
|
throw new Meteor.Error('error-deprecated', `The endpoint ${route} should be removed`);
|
|
}
|
|
|
|
// 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() {
|
|
this.queryOperations = options.queryOperations;
|
|
this.queryFields = options.queryFields;
|
|
this.logger = logger;
|
|
|
|
const authToken = this.request.headers.get('x-auth-token');
|
|
this.token = Accounts._hashLoginToken(String(authToken))!;
|
|
|
|
const objectForRateLimitMatch = {
|
|
IPAddr: this.requestIp,
|
|
route: api.getFullRouteName(route, this.request.method.toLowerCase()),
|
|
};
|
|
|
|
let result;
|
|
|
|
const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token };
|
|
this.connection = connection;
|
|
|
|
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 (
|
|
this.userId &&
|
|
(await api.processTwoFactor({
|
|
userId: this.userId,
|
|
request: this.request,
|
|
options: _options,
|
|
connection: connection as unknown as IMethodConnection,
|
|
}))
|
|
) {
|
|
this.twoFactorChecked = true;
|
|
}
|
|
|
|
this.parseJsonQuery = () => api.parseJsonQuery(this);
|
|
|
|
if (options.applyMeteorContext) {
|
|
const invocation = APIClass.createMeteorInvocation(connection, this.userId, this.token);
|
|
result = await invocation
|
|
.applyInvocation(() => originalAction.apply(this))
|
|
.finally(() => invocation[Symbol.asyncDispose]());
|
|
} else {
|
|
result = await originalAction.apply(this);
|
|
}
|
|
} catch (e: any) {
|
|
result = ((e: any) => {
|
|
switch (e.error) {
|
|
case 'error-too-many-requests':
|
|
return api.tooManyRequests(typeof e === 'string' ? e : e.message);
|
|
case 'unauthorized':
|
|
case 'error-unauthorized':
|
|
if (applyBreakingChanges) {
|
|
return api.unauthorized(typeof e === 'string' ? e : e.message);
|
|
}
|
|
return api.forbidden(typeof e === 'string' ? e : e.message);
|
|
case 'forbidden':
|
|
case 'error-forbidden':
|
|
if (applyBreakingChanges) {
|
|
return api.forbidden(typeof e === 'string' ? e : e.message);
|
|
}
|
|
return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e);
|
|
default:
|
|
return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e);
|
|
}
|
|
})(e);
|
|
} finally {
|
|
delete Accounts._accountData[connection.id];
|
|
}
|
|
|
|
return result;
|
|
} as InnerAction<any, any, any>;
|
|
// 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;
|
|
this.router[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](
|
|
`/${route}`.replaceAll('//', '/'),
|
|
{ ..._options, tags } as TypedOptions,
|
|
authenticationMiddlewareForHono(this, {
|
|
authRequired: options.authRequired,
|
|
authOrAnonRequired: options.authOrAnonRequired,
|
|
userWithoutUsername: options.userWithoutUsername,
|
|
logger,
|
|
}),
|
|
permissionsMiddleware(_options as TypedOptions),
|
|
license(_options as TypedOptions, License),
|
|
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action,
|
|
);
|
|
this._routes.push({
|
|
path: route,
|
|
options: _options,
|
|
endpoints: operations[method as keyof Operations<TPathPattern, TOptions>] as unknown as Record<string, string>,
|
|
});
|
|
|
|
this.registerTypedRoutesLegacy(method as Method, route, {
|
|
...options,
|
|
...operations[method as keyof Operations<TPathPattern, TOptions>],
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
public async authenticatedRoute(routeContext: APIActionContext): Promise<IUser | null> {
|
|
const userId = routeContext.request.headers.get('x-user-id');
|
|
const userToken = routeContext.request.headers.get('x-auth-token');
|
|
|
|
if (userId && userToken) {
|
|
return Users.findOne(
|
|
{
|
|
'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(userToken),
|
|
'_id': userId,
|
|
},
|
|
{
|
|
projection: getDefaultUserFields(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const method of this.authMethods) {
|
|
const user = await method(routeContext);
|
|
|
|
if (user) {
|
|
return user;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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;
|
|
this.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.get('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(
|
|
'login',
|
|
{ authRequired: false, userWithoutUsername: true },
|
|
{
|
|
async post() {
|
|
const request = this.request as unknown as Request;
|
|
const args = loginCompatibility(this.bodyParams, request);
|
|
|
|
const invocation = new DDPCommon.MethodInvocation({
|
|
connection: generateConnection(this.requestIp || '', this.request.headers),
|
|
});
|
|
|
|
try {
|
|
const auth = await DDP._CurrentInvocation.withValue(invocation as any, async () => Meteor.callAsync('login', args));
|
|
const user = await Users.findOne(
|
|
{
|
|
_id: auth.id,
|
|
},
|
|
{
|
|
projection: getDefaultUserFields(),
|
|
},
|
|
);
|
|
|
|
if (!user) {
|
|
return self.unauthorized();
|
|
}
|
|
|
|
return self.success({
|
|
status: 'success',
|
|
data: {
|
|
userId: user._id,
|
|
authToken: auth.token,
|
|
me: await getUserInfo(user || ({} as IUser)),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (!(error instanceof Meteor.Error)) {
|
|
return self.internalError();
|
|
}
|
|
|
|
const result = self.unauthorized();
|
|
|
|
if (!applyBreakingChanges) {
|
|
Object.assign(result.body, {
|
|
status: 'error',
|
|
error: error.error,
|
|
details: error.details,
|
|
message: error.reason || error.message,
|
|
...(error.reason === 'User not found' && {
|
|
error: 'Unauthorized',
|
|
message: 'Unauthorized',
|
|
}),
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
const logout = async function <
|
|
This extends TypedThis<{
|
|
authRequired: true;
|
|
response: any;
|
|
}>,
|
|
>(this: This): Promise<{ status: string; data: { message: string } }> {
|
|
// Remove the given auth token from the user's account
|
|
const hashedToken = this.token;
|
|
const tokenLocation = 'services.resume.loginTokens.hashedToken';
|
|
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.userId },
|
|
{
|
|
$pull: tokenRemovalQuery,
|
|
},
|
|
);
|
|
|
|
await Sessions.logoutBySessionIdAndUserId({ loginToken: hashedToken, userId: this.userId });
|
|
void notifyOnUserChangeAsync(async () => {
|
|
const userTokens = await Users.findOneById(this.userId, { projection: { [tokenPath]: 1 } });
|
|
if (!userTokens) {
|
|
return;
|
|
}
|
|
|
|
const diff = { [tokenPath]: getNestedProp(userTokens, tokenPath) };
|
|
|
|
return { clientAction: 'updated', id: this.userId, diff };
|
|
});
|
|
|
|
const response = {
|
|
status: 'success',
|
|
data: {
|
|
message: "You've been logged out!",
|
|
},
|
|
};
|
|
|
|
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 any) as any;
|
|
},
|
|
async post() {
|
|
return logout.call(this as any) as any;
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
static createMeteorInvocation(
|
|
connection: {
|
|
id: string;
|
|
close: () => void;
|
|
clientAddress: string;
|
|
httpHeaders: Record<string, any>;
|
|
},
|
|
userId?: string,
|
|
token?: string,
|
|
) {
|
|
const invocation = new DDPCommon.MethodInvocation({
|
|
connection,
|
|
isSimulation: false,
|
|
userId,
|
|
});
|
|
|
|
Accounts._accountData[connection.id] = {
|
|
connection,
|
|
};
|
|
if (token) {
|
|
Accounts._setAccountData(connection.id, 'loginToken', token);
|
|
}
|
|
|
|
return {
|
|
invocation,
|
|
applyInvocation: <F extends () => Promise<any>>(action: F): ReturnType<F> => {
|
|
return DDP._CurrentInvocation.withValue(invocation as any, async () => action()) as ReturnType<F>;
|
|
},
|
|
[Symbol.asyncDispose]() {
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
}
|
|
}
|